Compare commits
173 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 | |||
| 430187c28b | |||
| f5b50c4484 | |||
| 4a0f88b17d | |||
| 82996aa8e6 | |||
| 712cb06442 | |||
| 4d77279e7e | |||
| 6079c62709 | |||
| 37ef27e8ed | |||
| db2218f395 | |||
| bc28fee641 | |||
| 15fceed536 | |||
| afa82e0989 | |||
| b9ef09d26e | |||
| 7d66967122 | |||
| 2f8404d2ef | |||
| 2b92be02b9 | |||
| 056f0d8808 | |||
| 42b0037376 | |||
| de7639a3e9 | |||
| 8738735f0d | |||
| e80f3c70b6 | |||
| 24cc5fd0f0 | |||
| c5153d68bb | |||
| 0e56b5befb | |||
| c5e7479ee4 | |||
| 8a0c59d7e8 | |||
| 828e3e6cf6 | |||
| 7de4efeb02 | |||
| 6f0d142639 | |||
| 11cc6715ed | |||
| f90bff01db | |||
| 6add4b4acc | |||
| 325106920f | |||
| 8aaab82287 | |||
| b3ae200b11 | |||
| 71d2c39f01 | |||
| a68f0cf222 | |||
| 83eba4bec5 | |||
| 10bd0c0e4d | |||
| 865c22a884 | |||
| d48099f0d0 | |||
| bd1d1f1c0e | |||
| 327e9c5f94 | |||
| d2d2e5f68f | |||
| d692232191 | |||
| 65943597d4 | |||
| 27ed65114e | |||
| 397d3c5c4f | |||
| dc9c0c950c | |||
| 867bf18116 |
@@ -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 uses the same verifier but exchanges the API key for an HTTP-only secure cookie at `/dashboard/login`. `Dashboard:AllowAnonymousLocalhost` bypasses cookie auth on loopback when explicitly 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.
|
||||||
+5
-5
@@ -3,15 +3,15 @@
|
|||||||
This document describes how to perform a comprehensive, per-module code review of
|
This document describes how to perform a comprehensive, per-module code review of
|
||||||
the `mxaccessgw` codebase and how to track findings to resolution.
|
the `mxaccessgw` codebase and how to track findings to resolution.
|
||||||
|
|
||||||
A **module** is one buildable project under `src/` (e.g. `src/MxGateway.Worker`)
|
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
|
or one language client under `clients/` (e.g. `clients/rust`). Each module has
|
||||||
its own folder under `code-reviews/` containing a single `findings.md`.
|
its own folder under `code-reviews/` containing a single `findings.md`.
|
||||||
|
|
||||||
## 1. Before you start
|
## 1. Before you start
|
||||||
|
|
||||||
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
||||||
- For a `src/` project, `<Module>` is the project name with the `MxGateway.`
|
- For a `src/` project, `<Module>` is the project name with the `ZB.MOM.WW.MxGateway.`
|
||||||
prefix stripped — `src/MxGateway.Server` is reviewed in `code-reviews/Server/`.
|
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
|
- For a language client, `<Module>` is `Client.<Lang>` — `clients/rust` is
|
||||||
reviewed in `code-reviews/Client.Rust/`.
|
reviewed in `code-reviews/Client.Rust/`.
|
||||||
2. Identify the design context for the module:
|
2. Identify the design context for the module:
|
||||||
@@ -65,8 +65,8 @@ means the checklist is completed even where it produces no findings — record
|
|||||||
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
||||||
Options pattern, separation of concerns, additive-only contract evolution.
|
Options pattern, separation of concerns, additive-only contract evolution.
|
||||||
9. **Testing coverage** — are the module's behaviours covered by tests
|
9. **Testing coverage** — are the module's behaviours covered by tests
|
||||||
(`src/MxGateway.Tests`, `src/MxGateway.Worker.Tests`,
|
(`src/ZB.MOM.WW.MxGateway.Tests`, `src/ZB.MOM.WW.MxGateway.Worker.Tests`,
|
||||||
`src/MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
`src/ZB.MOM.WW.MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
||||||
edge-case tests.
|
edge-case tests.
|
||||||
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
||||||
undocumented non-obvious behaviour.
|
undocumented non-obvious behaviour.
|
||||||
|
|||||||
+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>
|
||||||
@@ -16,9 +16,9 @@ Recommended layout:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
clients/dotnet/
|
clients/dotnet/
|
||||||
MxGateway.Client.sln
|
ZB.MOM.WW.MxGateway.Client.slnx
|
||||||
MxGateway.Client/
|
ZB.MOM.WW.MxGateway.Client/
|
||||||
MxGateway.Client.csproj
|
ZB.MOM.WW.MxGateway.Client.csproj
|
||||||
GatewayClient.cs
|
GatewayClient.cs
|
||||||
MxGatewaySession.cs
|
MxGatewaySession.cs
|
||||||
MxGatewayClientOptions.cs
|
MxGatewayClientOptions.cs
|
||||||
@@ -26,14 +26,12 @@ clients/dotnet/
|
|||||||
Conversion/
|
Conversion/
|
||||||
Errors/
|
Errors/
|
||||||
Generated/
|
Generated/
|
||||||
MxGateway.Client.Cli/
|
ZB.MOM.WW.MxGateway.Client.Cli/
|
||||||
MxGateway.Client.Cli.csproj
|
ZB.MOM.WW.MxGateway.Client.Cli.csproj
|
||||||
Program.cs
|
Program.cs
|
||||||
Commands/
|
Commands/
|
||||||
MxGateway.Client.Tests/
|
ZB.MOM.WW.MxGateway.Client.Tests/
|
||||||
MxGateway.Client.Tests.csproj
|
ZB.MOM.WW.MxGateway.Client.Tests.csproj
|
||||||
MxGateway.Client.IntegrationTests/
|
|
||||||
MxGateway.Client.IntegrationTests.csproj
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Target framework:
|
Target framework:
|
||||||
@@ -43,7 +41,7 @@ Target framework:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The scaffold uses a project reference to
|
The scaffold uses a project reference to
|
||||||
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and
|
`src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` for generated protobuf and
|
||||||
gRPC types. `clients/dotnet/generated` remains reserved for client-local
|
gRPC types. `clients/dotnet/generated` remains reserved for client-local
|
||||||
generator output if the .NET client later needs to decouple from the contracts
|
generator output if the .NET client later needs to decouple from the contracts
|
||||||
project.
|
project.
|
||||||
@@ -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:
|
||||||
@@ -166,7 +182,7 @@ reply.EnsureMxAccessSuccess();
|
|||||||
|
|
||||||
## Test CLI
|
## Test CLI
|
||||||
|
|
||||||
Project: `MxGateway.Client.Cli`.
|
Project: `ZB.MOM.WW.MxGateway.Client.Cli`.
|
||||||
|
|
||||||
Command examples:
|
Command examples:
|
||||||
|
|
||||||
|
|||||||
@@ -1,520 +0,0 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
|
||||||
using MxGateway.Client.Cli;
|
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
|
||||||
|
|
||||||
/// <summary>Tests for the CLI command interface.</summary>
|
|
||||||
public sealed class MxGatewayClientCliTests
|
|
||||||
{
|
|
||||||
/// <summary>Verifies that the version command prints compiled protocol versions.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Run_Version_PrintsCompiledProtocolVersions()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
|
|
||||||
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
Assert.Contains("gateway-protocol=3", output.ToString());
|
|
||||||
Assert.Contains("worker-protocol=1", output.ToString());
|
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
Assert.Contains("\"gatewayProtocolVersion\":3", output.ToString());
|
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new();
|
|
||||||
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Kind = MxCommandKind.Write,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
});
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"write",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--session-id",
|
|
||||||
"session-fixture",
|
|
||||||
"--server-handle",
|
|
||||||
"12",
|
|
||||||
"--item-handle",
|
|
||||||
"34",
|
|
||||||
"--type",
|
|
||||||
"int32",
|
|
||||||
"--value",
|
|
||||||
"123",
|
|
||||||
"--json",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
|
||||||
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
|
||||||
Assert.Equal(123, request.Command.Write.Value.Int32Value);
|
|
||||||
Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
|
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"open-session",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"secret-api-key",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => throw new InvalidOperationException("boom secret-api-key"));
|
|
||||||
|
|
||||||
Assert.Equal(1, exitCode);
|
|
||||||
Assert.DoesNotContain("secret-api-key", error.ToString());
|
|
||||||
Assert.Contains("[redacted]", error.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new();
|
|
||||||
fakeClient.Events.Add(new MxEvent
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Family = MxEventFamily.OnDataChange,
|
|
||||||
WorkerSequence = 1,
|
|
||||||
});
|
|
||||||
fakeClient.Events.Add(new MxEvent
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Family = MxEventFamily.OnWriteComplete,
|
|
||||||
WorkerSequence = 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"stream-events",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--session-id",
|
|
||||||
"session-fixture",
|
|
||||||
"--max-events",
|
|
||||||
"1",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
Assert.Contains("workerSequence", output.ToString());
|
|
||||||
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new()
|
|
||||||
{
|
|
||||||
InvokeFailure = new InvalidOperationException("register failed"),
|
|
||||||
};
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"smoke",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--item",
|
|
||||||
"Area001.Pump001.Speed",
|
|
||||||
"--json",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.Equal(1, exitCode);
|
|
||||||
CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
|
|
||||||
Assert.Equal("session-fixture", closeRequest.SessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new()
|
|
||||||
{
|
|
||||||
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
|
|
||||||
};
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"galaxy-test-connection",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--json",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
|
|
||||||
Assert.Contains("\"ok\": true", output.ToString());
|
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new();
|
|
||||||
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
||||||
{
|
|
||||||
NextPageToken = "7:1",
|
|
||||||
TotalObjectCount = 2,
|
|
||||||
Objects =
|
|
||||||
{
|
|
||||||
new GalaxyObject
|
|
||||||
{
|
|
||||||
GobjectId = 7,
|
|
||||||
TagName = "DelmiaReceiver_001",
|
|
||||||
ContainedName = "DelmiaReceiver",
|
|
||||||
ParentGobjectId = 1,
|
|
||||||
Attributes =
|
|
||||||
{
|
|
||||||
new GalaxyAttribute
|
|
||||||
{
|
|
||||||
AttributeName = "DownloadPath",
|
|
||||||
FullTagReference = "DelmiaReceiver_001.DownloadPath",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
||||||
{
|
|
||||||
TotalObjectCount = 2,
|
|
||||||
Objects =
|
|
||||||
{
|
|
||||||
new GalaxyObject
|
|
||||||
{
|
|
||||||
GobjectId = 8,
|
|
||||||
TagName = "DelmiaReceiver_002",
|
|
||||||
ContainedName = "DelmiaReceiver",
|
|
||||||
ParentGobjectId = 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"galaxy-discover",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
|
|
||||||
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
|
|
||||||
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
|
|
||||||
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
|
|
||||||
string text = output.ToString();
|
|
||||||
Assert.Contains("objects=2", text);
|
|
||||||
Assert.Contains("DelmiaReceiver_001", text);
|
|
||||||
Assert.Contains("DelmiaReceiver_002", text);
|
|
||||||
Assert.Contains("attributes=1", text);
|
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new();
|
|
||||||
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
|
||||||
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
||||||
{
|
|
||||||
Sequence = 1,
|
|
||||||
ObservedAt = Timestamp.FromDateTime(deploy),
|
|
||||||
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
|
|
||||||
TimeOfLastDeployPresent = true,
|
|
||||||
ObjectCount = 5,
|
|
||||||
AttributeCount = 17,
|
|
||||||
});
|
|
||||||
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
||||||
{
|
|
||||||
Sequence = 2,
|
|
||||||
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
||||||
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
||||||
TimeOfLastDeployPresent = true,
|
|
||||||
ObjectCount = 6,
|
|
||||||
AttributeCount = 18,
|
|
||||||
});
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"galaxy-watch",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--last-seen-deploy-time",
|
|
||||||
"2026-04-28T14:00:00Z",
|
|
||||||
"--max-events",
|
|
||||||
"2",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
|
|
||||||
Assert.NotNull(request.LastSeenDeployTime);
|
|
||||||
string text = output.ToString();
|
|
||||||
Assert.Contains("sequence=1", text);
|
|
||||||
Assert.Contains("sequence=2", text);
|
|
||||||
Assert.Contains("objects=5", text);
|
|
||||||
Assert.Contains("attributes=18", text);
|
|
||||||
Assert.Equal(string.Empty, error.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
|
||||||
{
|
|
||||||
using var output = new StringWriter();
|
|
||||||
using var error = new StringWriter();
|
|
||||||
FakeCliClient fakeClient = new();
|
|
||||||
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
||||||
{
|
|
||||||
Sequence = 42,
|
|
||||||
ObjectCount = 99,
|
|
||||||
AttributeCount = 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
||||||
[
|
|
||||||
"galaxy-watch",
|
|
||||||
"--endpoint",
|
|
||||||
"http://localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"test-api-key",
|
|
||||||
"--max-events",
|
|
||||||
"1",
|
|
||||||
"--json",
|
|
||||||
],
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
_ => fakeClient);
|
|
||||||
|
|
||||||
Assert.Equal(0, exitCode);
|
|
||||||
string text = output.ToString();
|
|
||||||
Assert.Contains("\"sequence\": \"42\"", text);
|
|
||||||
Assert.Contains("\"objectCount\": 99", text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Fake CLI client for testing.</summary>
|
|
||||||
private sealed class FakeCliClient : IMxGatewayCliClient
|
|
||||||
{
|
|
||||||
/// <summary>Queue of invoke replies to return.</summary>
|
|
||||||
public Queue<MxCommandReply> InvokeReplies { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>List of received invoke requests.</summary>
|
|
||||||
public List<MxCommandRequest> InvokeRequests { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>List of received close session requests.</summary>
|
|
||||||
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>List of events to yield when streaming.</summary>
|
|
||||||
public List<MxEvent> Events { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>Exception to throw on invoke, if any.</summary>
|
|
||||||
public Exception? InvokeFailure { get; init; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<OpenSessionReply> OpenSessionAsync(
|
|
||||||
OpenSessionRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.FromResult(new OpenSessionReply
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
GatewayProtocolVersion = 1,
|
|
||||||
WorkerProtocolVersion = 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<CloseSessionReply> CloseSessionAsync(
|
|
||||||
CloseSessionRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
CloseSessionRequests.Add(request);
|
|
||||||
return Task.FromResult(new CloseSessionReply
|
|
||||||
{
|
|
||||||
SessionId = request.SessionId,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
FinalState = SessionState.Closed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<MxCommandReply> InvokeAsync(
|
|
||||||
MxCommandRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
InvokeRequests.Add(request);
|
|
||||||
if (InvokeFailure is not null)
|
|
||||||
{
|
|
||||||
throw InvokeFailure;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(InvokeReplies.Dequeue());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
|
||||||
StreamEventsRequest request,
|
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
foreach (MxEvent gatewayEvent in Events)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
await Task.Yield();
|
|
||||||
yield return gatewayEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Galaxy test connection reply to return.</summary>
|
|
||||||
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
|
||||||
|
|
||||||
/// <summary>Galaxy get last deploy time reply to return.</summary>
|
|
||||||
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
|
|
||||||
|
|
||||||
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
|
||||||
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
|
||||||
|
|
||||||
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>List of received galaxy test connection requests.</summary>
|
|
||||||
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>List of received galaxy get last deploy time requests.</summary>
|
|
||||||
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>List of received galaxy discover hierarchy requests.</summary>
|
|
||||||
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
|
||||||
TestConnectionRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
GalaxyTestConnectionRequests.Add(request);
|
|
||||||
return Task.FromResult(GalaxyTestConnectionReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
|
|
||||||
GetLastDeployTimeRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
GalaxyGetLastDeployTimeRequests.Add(request);
|
|
||||||
return Task.FromResult(GalaxyGetLastDeployTimeReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
|
|
||||||
DiscoverHierarchyRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
GalaxyDiscoverHierarchyRequests.Add(request);
|
|
||||||
return Task.FromResult(
|
|
||||||
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
|
||||||
? reply
|
|
||||||
: GalaxyDiscoverHierarchyReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>List of received galaxy watch deploy events requests.</summary>
|
|
||||||
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
|
|
||||||
|
|
||||||
/// <summary>List of deploy events to yield when watching.</summary>
|
|
||||||
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
|
|
||||||
WatchDeployEventsRequest request,
|
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
GalaxyWatchDeployEventsRequests.Add(request);
|
|
||||||
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
await Task.Yield();
|
|
||||||
yield return deployEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
using Grpc.Core;
|
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
|
||||||
|
|
||||||
/// <summary>Tests for the shared gRPC-to-native exception mapping used by the transports.</summary>
|
|
||||||
public sealed class RpcExceptionMapperTests
|
|
||||||
{
|
|
||||||
/// <summary>Verifies that an unauthenticated status maps to the authentication exception.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Map_UnauthenticatedStatus_ProducesAuthenticationException()
|
|
||||||
{
|
|
||||||
RpcException rpc = new(new Status(StatusCode.Unauthenticated, "no key"));
|
|
||||||
|
|
||||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
||||||
|
|
||||||
MxGatewayAuthenticationException authentication =
|
|
||||||
Assert.IsType<MxGatewayAuthenticationException>(mapped);
|
|
||||||
Assert.Equal(StatusCode.Unauthenticated, authentication.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that a permission-denied status maps to the authorization exception.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Map_PermissionDeniedStatus_ProducesAuthorizationException()
|
|
||||||
{
|
|
||||||
RpcException rpc = new(new Status(StatusCode.PermissionDenied, "missing scope"));
|
|
||||||
|
|
||||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
||||||
|
|
||||||
MxGatewayAuthorizationException authorization =
|
|
||||||
Assert.IsType<MxGatewayAuthorizationException>(mapped);
|
|
||||||
Assert.Equal(StatusCode.PermissionDenied, authorization.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that a cancelled status maps to OperationCanceledException.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Map_CancelledStatus_ProducesOperationCanceledException()
|
|
||||||
{
|
|
||||||
RpcException rpc = new(new Status(StatusCode.Cancelled, "cancelled"));
|
|
||||||
|
|
||||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.IsType<OperationCanceledException>(mapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that non-auth statuses surface the originating gRPC status code on the
|
|
||||||
/// mapped exception so callers can distinguish transient from permanent failures
|
|
||||||
/// without reflecting into InnerException.
|
|
||||||
/// </summary>
|
|
||||||
[Theory]
|
|
||||||
[InlineData(StatusCode.NotFound)]
|
|
||||||
[InlineData(StatusCode.InvalidArgument)]
|
|
||||||
[InlineData(StatusCode.ResourceExhausted)]
|
|
||||||
[InlineData(StatusCode.FailedPrecondition)]
|
|
||||||
[InlineData(StatusCode.Unavailable)]
|
|
||||||
[InlineData(StatusCode.Internal)]
|
|
||||||
public void Map_NonAuthStatus_CarriesStatusCodeOnMxGatewayException(StatusCode statusCode)
|
|
||||||
{
|
|
||||||
RpcException rpc = new(new Status(statusCode, "boom"));
|
|
||||||
|
|
||||||
Exception mapped = RpcExceptionMapper.Map(rpc, CancellationToken.None);
|
|
||||||
|
|
||||||
MxGatewayException gatewayException = Assert.IsType<MxGatewayException>(mapped);
|
|
||||||
Assert.Equal(statusCode, gatewayException.StatusCode);
|
|
||||||
Assert.Same(rpc, gatewayException.InnerException);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that an MxGatewayException built without a gRPC status reports a null StatusCode.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void StatusCode_IsNull_WhenNoGrpcStatusProvided()
|
|
||||||
{
|
|
||||||
MxGatewayException gatewayException = new("plain failure");
|
|
||||||
|
|
||||||
Assert.Null(gatewayException.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.0.31903.59
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Debug|x64 = Debug|x64
|
|
||||||
Debug|x86 = Debug|x86
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
Release|x64 = Release|x64
|
|
||||||
Release|x86 = Release|x86
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
namespace MxGateway.Client;
|
|
||||||
|
|
||||||
public sealed record DiscoverHierarchyOptions
|
|
||||||
{
|
|
||||||
public int? RootGobjectId { get; init; }
|
|
||||||
|
|
||||||
public string? RootTagName { get; init; }
|
|
||||||
|
|
||||||
public string? RootContainedPath { get; init; }
|
|
||||||
|
|
||||||
public int? MaxDepth { get; init; }
|
|
||||||
|
|
||||||
public IReadOnlyList<int> CategoryIds { get; init; } = Array.Empty<int>();
|
|
||||||
|
|
||||||
public IReadOnlyList<string> TemplateChainContains { get; init; } = Array.Empty<string>();
|
|
||||||
|
|
||||||
public string? TagNameGlob { get; init; }
|
|
||||||
|
|
||||||
public bool? IncludeAttributes { get; init; }
|
|
||||||
|
|
||||||
public bool AlarmBearingOnly { get; init; }
|
|
||||||
|
|
||||||
public bool HistorizedOnly { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
using System.Runtime.CompilerServices;
|
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using Grpc.Core;
|
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps low-level <see cref="RpcException"/>s raised by the gRPC stack to the client's
|
|
||||||
/// native exception hierarchy. Shared by every gateway and Galaxy Repository transport
|
|
||||||
/// so the gRPC-to-native translation has exactly one implementation.
|
|
||||||
/// </summary>
|
|
||||||
internal static class RpcExceptionMapper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Translates a <see cref="RpcException"/> into the most specific native exception type.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="exception">The gRPC exception to translate.</param>
|
|
||||||
/// <param name="cancellationToken">
|
|
||||||
/// The cancellation token of the originating call; used to distinguish a caller-driven
|
|
||||||
/// cancellation from a server-side <see cref="StatusCode.Cancelled"/> status.
|
|
||||||
/// </param>
|
|
||||||
/// <returns>
|
|
||||||
/// An <see cref="OperationCanceledException"/> when the call was cancelled, a typed
|
|
||||||
/// authentication/authorization exception for auth statuses, or an
|
|
||||||
/// <see cref="MxGatewayException"/> carrying the originating gRPC <see cref="StatusCode"/>.
|
|
||||||
/// </returns>
|
|
||||||
public static Exception Map(
|
|
||||||
RpcException exception,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(exception);
|
|
||||||
|
|
||||||
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
|
|
||||||
{
|
|
||||||
return new OperationCanceledException(
|
|
||||||
exception.Status.Detail,
|
|
||||||
exception,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return exception.StatusCode switch
|
|
||||||
{
|
|
||||||
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
|
||||||
exception.Status.Detail,
|
|
||||||
statusCode: exception.StatusCode,
|
|
||||||
innerException: exception),
|
|
||||||
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
|
||||||
exception.Status.Detail,
|
|
||||||
statusCode: exception.StatusCode,
|
|
||||||
innerException: exception),
|
|
||||||
_ => new MxGatewayException(
|
|
||||||
exception.Status.Detail,
|
|
||||||
exception.StatusCode,
|
|
||||||
exception),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+120
-38
@@ -7,11 +7,11 @@ CLI, and unit tests.
|
|||||||
|
|
||||||
| Project | Purpose |
|
| Project | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
|
| `ZB.MOM.WW.MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
|
||||||
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
| `ZB.MOM.WW.MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
||||||
| `MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
|
| `ZB.MOM.WW.MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
|
||||||
|
|
||||||
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
|
The projects reference `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` so
|
||||||
the client compiles against the same generated protobuf and gRPC types as the
|
the client compiles against the same generated protobuf and gRPC types as the
|
||||||
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
||||||
future client build switches to client-local `Grpc.Tools` generation.
|
future client build switches to client-local `Grpc.Tools` generation.
|
||||||
@@ -19,8 +19,8 @@ future client build switches to client-local `Grpc.Tools` generation.
|
|||||||
## Build And Test
|
## Build And Test
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet build clients/dotnet/MxGateway.Client.sln
|
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
|
||||||
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
|
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Packaging
|
## Packaging
|
||||||
@@ -29,8 +29,8 @@ Create local library and CLI artifacts from the repository root:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
|
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
|
||||||
dotnet pack clients/dotnet/MxGateway.Client/MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
|
dotnet pack clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
|
||||||
dotnet publish clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
|
dotnet publish clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
|
||||||
```
|
```
|
||||||
|
|
||||||
The library package references the shared contracts project at build time. The
|
The library package references the shared contracts project at build time. The
|
||||||
@@ -39,11 +39,11 @@ published CLI runs from `artifacts/clients/dotnet/mxgw-dotnet`.
|
|||||||
## Regenerating Protobuf Bindings
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
The .NET client uses the generated C# types from
|
The .NET client uses the generated C# types from
|
||||||
`src/MxGateway.Contracts/Generated`. Regenerate those files through the
|
`src/ZB.MOM.WW.MxGateway.Contracts/Generated`. Regenerate those files through the
|
||||||
contracts project:
|
contracts project:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
|
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## Client Usage
|
## Client Usage
|
||||||
@@ -84,6 +84,15 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
|||||||
available, and command helpers have `*RawAsync` variants when callers need the
|
available, and command helpers have `*RawAsync` variants when callers need the
|
||||||
complete `MxCommandReply`.
|
complete `MxCommandReply`.
|
||||||
|
|
||||||
|
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
||||||
|
the active alarms the gateway's central monitor currently holds),
|
||||||
|
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
||||||
|
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
|
||||||
|
reference, optional comment, ack target). All three accept a cancellation
|
||||||
|
token and pass through the `MxGateway:Alarms` configuration on the
|
||||||
|
server — when alarms are disabled, the gateway returns an empty list / empty
|
||||||
|
stream rather than failing.
|
||||||
|
|
||||||
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||||
the first `CloseSessionReply` instead of sending another close request.
|
the first `CloseSessionReply` instead of sending another close request.
|
||||||
|
|
||||||
@@ -112,31 +121,22 @@ can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
|
|||||||
itself rejects a command. `MxAccessException.Reply` contains the raw generated
|
itself rejects a command. `MxAccessException.Reply` contains the raw generated
|
||||||
reply.
|
reply.
|
||||||
|
|
||||||
When a gRPC call itself fails, the transport maps the underlying
|
|
||||||
`RpcException` to a native exception: `Unauthenticated` becomes
|
|
||||||
`MxGatewayAuthenticationException`, `PermissionDenied` becomes
|
|
||||||
`MxGatewayAuthorizationException`, a cancelled call becomes
|
|
||||||
`OperationCanceledException`, and every other status becomes a base
|
|
||||||
`MxGatewayException`. `MxGatewayException.StatusCode` carries the originating
|
|
||||||
gRPC `Grpc.Core.StatusCode` (non-null whenever the failure came from a gRPC
|
|
||||||
status), so callers can distinguish a transient outage (`Unavailable`) from a
|
|
||||||
permanent error (`InvalidArgument`, `NotFound`) without downcasting
|
|
||||||
`InnerException`.
|
|
||||||
|
|
||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
The test CLI supports deterministic JSON output for automation:
|
The test CLI supports deterministic JSON output for automation:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- version --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 1 --json
|
||||||
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
|
||||||
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
```
|
```
|
||||||
|
|
||||||
`smoke` opens a session, registers a client, adds one item, advises it,
|
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||||
@@ -191,11 +191,59 @@ IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
|
|||||||
The CLI exposes the same operations:
|
The CLI exposes the same operations:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
dotnet run --project clients/dotnet/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
|
||||||
@@ -228,17 +276,28 @@ await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
|
|||||||
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
|
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
Use TLS options for a secured gateway:
|
Use TLS options for a secured gateway:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name 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:
|
||||||
@@ -248,9 +307,32 @@ $env:MXGATEWAY_INTEGRATION = '1'
|
|||||||
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
|
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
|
||||||
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||||
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
||||||
dotnet run --project clients/dotnet/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)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
namespace MxGateway.Client.Cli;
|
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
||||||
|
|
||||||
/// <summary>Parses command-line arguments into flags and named values.</summary>
|
/// <summary>Parses command-line arguments into flags and named values.</summary>
|
||||||
internal sealed class CliArguments
|
internal sealed class CliArguments
|
||||||
+24
-3
@@ -1,7 +1,7 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Client.Cli;
|
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
||||||
|
|
||||||
public interface IMxGatewayCliClient : IAsyncDisposable
|
public interface IMxGatewayCliClient : IAsyncDisposable
|
||||||
{
|
{
|
||||||
@@ -45,6 +45,27 @@ public interface IMxGatewayCliClient : IAsyncDisposable
|
|||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acknowledges an active MXAccess alarm condition through the gateway.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||||
|
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||||
|
/// snapshot followed by live transitions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests connection to the Galaxy Repository.
|
/// Tests connection to the Galaxy Repository.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
+20
-4
@@ -1,8 +1,8 @@
|
|||||||
using MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Client.Cli;
|
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
||||||
|
|
||||||
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
||||||
{
|
{
|
||||||
@@ -52,6 +52,22 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
|||||||
return _client.StreamEventsAsync(request, cancellationToken);
|
return _client.StreamEventsAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
|
AcknowledgeAlarmRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.AcknowledgeAlarmAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.StreamAlarmsAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace MxGateway.Client.Cli;
|
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
||||||
|
|
||||||
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
|
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
|
||||||
internal static class MxGatewayCliSecretRedactor
|
internal static class MxGatewayCliSecretRedactor
|
||||||
+780
-31
@@ -1,11 +1,11 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Client.Cli;
|
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
||||||
|
|
||||||
/// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary>
|
/// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary>
|
||||||
public static class MxGatewayClientCli
|
public static class MxGatewayClientCli
|
||||||
@@ -16,6 +16,8 @@ public static class MxGatewayClientCli
|
|||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
|
||||||
|
|
||||||
/// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary>
|
/// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary>
|
||||||
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
||||||
/// <param name="standardOutput">TextWriter for command output.</param>
|
/// <param name="standardOutput">TextWriter for command output.</param>
|
||||||
@@ -25,7 +27,7 @@ public static class MxGatewayClientCli
|
|||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
TextWriter standardError)
|
TextWriter standardError)
|
||||||
{
|
{
|
||||||
return RunAsync(args, standardOutput, standardError)
|
return RunAsync(args, standardOutput, standardError, clientFactory: null, standardInput: null)
|
||||||
.GetAwaiter()
|
.GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
}
|
}
|
||||||
@@ -35,11 +37,13 @@ public static class MxGatewayClientCli
|
|||||||
/// <param name="standardOutput">TextWriter for command output.</param>
|
/// <param name="standardOutput">TextWriter for command output.</param>
|
||||||
/// <param name="standardError">TextWriter for error messages.</param>
|
/// <param name="standardError">TextWriter for error messages.</param>
|
||||||
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
||||||
|
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
||||||
public static Task<int> RunAsync(
|
public static Task<int> RunAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
TextWriter standardError,
|
TextWriter standardError,
|
||||||
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null)
|
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null,
|
||||||
|
TextReader? standardInput = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(args);
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
ArgumentNullException.ThrowIfNull(standardOutput);
|
ArgumentNullException.ThrowIfNull(standardOutput);
|
||||||
@@ -49,14 +53,17 @@ public static class MxGatewayClientCli
|
|||||||
args,
|
args,
|
||||||
standardOutput,
|
standardOutput,
|
||||||
standardError,
|
standardError,
|
||||||
clientFactory ?? CreateDefaultClient);
|
clientFactory ?? CreateDefaultClient,
|
||||||
|
standardInput ?? Console.In);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> RunCoreAsync(
|
private static async Task<int> RunCoreAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
TextWriter standardError,
|
TextWriter standardError,
|
||||||
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory)
|
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory,
|
||||||
|
TextReader standardInput,
|
||||||
|
bool forceJsonErrors = false)
|
||||||
{
|
{
|
||||||
if (args.Length is 0 || IsHelp(args[0]))
|
if (args.Length is 0 || IsHelp(args[0]))
|
||||||
{
|
{
|
||||||
@@ -65,6 +72,12 @@ public static class MxGatewayClientCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
string command = args[0].ToLowerInvariant();
|
string command = args[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
if (command is "batch")
|
||||||
|
{
|
||||||
|
return await RunBatchAsync(standardOutput, clientFactory, standardInput).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
CliArguments arguments = new(args.Skip(1));
|
CliArguments arguments = new(args.Skip(1));
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -101,8 +114,24 @@ public static class MxGatewayClientCli
|
|||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
"read-bulk" => await ReadBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"write-bulk" => await WriteBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"write2-bulk" => await Write2BulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"write-secured-bulk" => await WriteSecuredBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"write-secured2-bulk" => await WriteSecured2BulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"bench-read-bulk" => await BenchReadBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
"stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
"stream-alarms" => await StreamAlarmsAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
"acknowledge-alarm" => await AcknowledgeAlarmAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
|
.ConfigureAwait(false),
|
||||||
"write" => await WriteAsync(arguments, client, standardOutput, cancellation.Token)
|
"write" => await WriteAsync(arguments, client, standardOutput, cancellation.Token)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
"write2" => await Write2Async(arguments, client, standardOutput, cancellation.Token)
|
"write2" => await Write2Async(arguments, client, standardOutput, cancellation.Token)
|
||||||
@@ -125,7 +154,7 @@ public static class MxGatewayClientCli
|
|||||||
string? apiKey = arguments.GetOptional("api-key");
|
string? apiKey = arguments.GetOptional("api-key");
|
||||||
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
||||||
|
|
||||||
if (arguments.HasFlag("json"))
|
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||||
{
|
{
|
||||||
standardError.WriteLine(JsonSerializer.Serialize(
|
standardError.WriteLine(JsonSerializer.Serialize(
|
||||||
new { error = message, type = exception.GetType().Name },
|
new { error = message, type = exception.GetType().Name },
|
||||||
@@ -140,6 +169,86 @@ public static class MxGatewayClientCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the CLI in batch mode: reads one command line at a time from
|
||||||
|
/// <paramref name="standardInput"/>, dispatches it through the normal
|
||||||
|
/// routing, writes all output to <paramref name="standardOutput"/>, and
|
||||||
|
/// then appends <see cref="BatchEndOfRecord"/> as a sentinel so the
|
||||||
|
/// caller can delimit command results. Continues on failure; errors are
|
||||||
|
/// written as JSON to <paramref name="standardOutput"/> (not stderr) so
|
||||||
|
/// that the harness sees them inside the same delimited block. Exits 0
|
||||||
|
/// on EOF or empty line.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<int> RunBatchAsync(
|
||||||
|
TextWriter standardOutput,
|
||||||
|
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory,
|
||||||
|
TextReader standardInput)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string? line = await standardInput.ReadLineAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// EOF or empty line signals clean exit.
|
||||||
|
if (line is null || line.Length is 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on runs of ASCII whitespace — no quoting support by design.
|
||||||
|
string[] lineArgs = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Per-command output is buffered so we can redirect errors to stdout.
|
||||||
|
using StringWriter commandOutput = new();
|
||||||
|
|
||||||
|
// Errors in batch mode go to stdout (same delimited block), formatted as JSON.
|
||||||
|
// We use a capturing error writer and re-emit through commandOutput after the
|
||||||
|
// command returns, so the EOR sentinel always follows the complete result.
|
||||||
|
using StringWriter commandError = new();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunCoreAsync(
|
||||||
|
lineArgs,
|
||||||
|
commandOutput,
|
||||||
|
commandError,
|
||||||
|
clientFactory,
|
||||||
|
standardInput,
|
||||||
|
forceJsonErrors: true)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// Unexpected exception that escaped RunCoreAsync (shouldn't happen, but be safe).
|
||||||
|
// OperationCanceledException from long-running streaming commands
|
||||||
|
// (e.g. galaxy-watch hit by --timeout) is caught here too — the
|
||||||
|
// batch process must continue with the next command rather than
|
||||||
|
// unwinding.
|
||||||
|
commandError.WriteLine(JsonSerializer.Serialize(
|
||||||
|
new { error = exception.Message, type = exception.GetType().Name },
|
||||||
|
JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write any buffered normal output first.
|
||||||
|
string commandOutputText = commandOutput.ToString();
|
||||||
|
if (commandOutputText.Length > 0)
|
||||||
|
{
|
||||||
|
standardOutput.Write(commandOutputText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then any error output — in batch mode it belongs on stdout so the harness
|
||||||
|
// sees it inside the delimited record.
|
||||||
|
string commandErrorText = commandError.ToString();
|
||||||
|
if (commandErrorText.Length > 0)
|
||||||
|
{
|
||||||
|
standardOutput.Write(commandErrorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the end-of-record sentinel and flush so the harness can unblock.
|
||||||
|
standardOutput.WriteLine(BatchEndOfRecord);
|
||||||
|
await standardOutput.FlushAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
|
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
|
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
|
||||||
@@ -369,6 +478,499 @@ public static class MxGatewayClientCli
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Task<int> ReadBulkAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ReadBulkCommand command = new()
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
TimeoutMs = ParseTimeoutMs(arguments, defaultValue: 0),
|
||||||
|
};
|
||||||
|
command.TagAddresses.Add(ParseStringList(arguments.GetRequired("items")));
|
||||||
|
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.ReadBulk,
|
||||||
|
ReadBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> WriteBulkAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WriteBulkCommand command = new()
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<int> handles = ParseInt32List(arguments.GetRequired("item-handles"));
|
||||||
|
IReadOnlyList<MxValue> values = ParseValuesList(arguments);
|
||||||
|
int userId = arguments.GetInt32("user-id", 0);
|
||||||
|
EnsureSameLength(handles.Count, values.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < handles.Count; i++)
|
||||||
|
{
|
||||||
|
command.Entries.Add(new WriteBulkEntry
|
||||||
|
{
|
||||||
|
ItemHandle = handles[i],
|
||||||
|
Value = values[i],
|
||||||
|
UserId = userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteBulk,
|
||||||
|
WriteBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> Write2BulkAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Write2BulkCommand command = new()
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<int> handles = ParseInt32List(arguments.GetRequired("item-handles"));
|
||||||
|
IReadOnlyList<MxValue> values = ParseValuesList(arguments);
|
||||||
|
MxValue timestampValue = ParseTimestampValue(arguments);
|
||||||
|
int userId = arguments.GetInt32("user-id", 0);
|
||||||
|
EnsureSameLength(handles.Count, values.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < handles.Count; i++)
|
||||||
|
{
|
||||||
|
command.Entries.Add(new Write2BulkEntry
|
||||||
|
{
|
||||||
|
ItemHandle = handles[i],
|
||||||
|
Value = values[i],
|
||||||
|
TimestampValue = timestampValue,
|
||||||
|
UserId = userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write2Bulk,
|
||||||
|
Write2Bulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> WriteSecuredBulkAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WriteSecuredBulkCommand command = new()
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<int> handles = ParseInt32List(arguments.GetRequired("item-handles"));
|
||||||
|
IReadOnlyList<MxValue> values = ParseValuesList(arguments);
|
||||||
|
int currentUserId = arguments.GetInt32("current-user-id");
|
||||||
|
int verifierUserId = arguments.GetInt32("verifier-user-id", 0);
|
||||||
|
EnsureSameLength(handles.Count, values.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < handles.Count; i++)
|
||||||
|
{
|
||||||
|
command.Entries.Add(new WriteSecuredBulkEntry
|
||||||
|
{
|
||||||
|
ItemHandle = handles[i],
|
||||||
|
Value = values[i],
|
||||||
|
CurrentUserId = currentUserId,
|
||||||
|
VerifierUserId = verifierUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteSecuredBulk,
|
||||||
|
WriteSecuredBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> WriteSecured2BulkAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WriteSecured2BulkCommand command = new()
|
||||||
|
{
|
||||||
|
ServerHandle = arguments.GetInt32("server-handle"),
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<int> handles = ParseInt32List(arguments.GetRequired("item-handles"));
|
||||||
|
IReadOnlyList<MxValue> values = ParseValuesList(arguments);
|
||||||
|
MxValue timestampValue = ParseTimestampValue(arguments);
|
||||||
|
int currentUserId = arguments.GetInt32("current-user-id");
|
||||||
|
int verifierUserId = arguments.GetInt32("verifier-user-id", 0);
|
||||||
|
EnsureSameLength(handles.Count, values.Count);
|
||||||
|
|
||||||
|
for (int i = 0; i < handles.Count; i++)
|
||||||
|
{
|
||||||
|
command.Entries.Add(new WriteSecured2BulkEntry
|
||||||
|
{
|
||||||
|
ItemHandle = handles[i],
|
||||||
|
Value = values[i],
|
||||||
|
TimestampValue = timestampValue,
|
||||||
|
CurrentUserId = currentUserId,
|
||||||
|
VerifierUserId = verifierUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return InvokeAndWriteAsync(
|
||||||
|
arguments,
|
||||||
|
client,
|
||||||
|
output,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||||
|
WriteSecured2Bulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the bulk-write CLI's <c>--values</c> list. All entries share
|
||||||
|
/// the single <c>--type</c> argument; the comma-separated values are
|
||||||
|
/// each parsed via <see cref="ParseValue(string, string)"/> on a per-entry basis.
|
||||||
|
/// This keeps the CLI simple for e2e use (one type, N values) — callers
|
||||||
|
/// that need heterogeneous types per entry should drive the library
|
||||||
|
/// directly.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<MxValue> ParseValuesList(CliArguments arguments)
|
||||||
|
{
|
||||||
|
string type = arguments.GetRequired("type");
|
||||||
|
string[] values = ParseStringList(arguments.GetRequired("values")).ToArray();
|
||||||
|
MxValue[] result = new MxValue[values.Length];
|
||||||
|
for (int i = 0; i < values.Length; i++)
|
||||||
|
{
|
||||||
|
result[i] = ParseValue(type, values[i]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSameLength(int handles, int values)
|
||||||
|
{
|
||||||
|
if (handles != values)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Bulk write requires the same number of --item-handles ({handles}) and --values ({values}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the optional <c>--timeout-ms</c> argument as a non-negative
|
||||||
|
/// unsigned millisecond count. Mirrors the SDK-side <c>(uint)Math.Min</c>
|
||||||
|
/// guard on <c>MxGatewaySession.ReadBulkAsync</c>: a negative value
|
||||||
|
/// (e.g. <c>-1</c>, an easy copy-paste mistake for "unbounded") is
|
||||||
|
/// rejected loudly rather than silently wrapped to <c>~49.7 days</c>,
|
||||||
|
/// which would park one worker thread per pending tag for hours.
|
||||||
|
/// Resolves Client.Dotnet-021.
|
||||||
|
/// </summary>
|
||||||
|
private static uint ParseTimeoutMs(CliArguments arguments, int defaultValue)
|
||||||
|
{
|
||||||
|
int raw = arguments.GetInt32("timeout-ms", defaultValue);
|
||||||
|
if (raw < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"--timeout-ms must be a non-negative integer (use 0 for the gateway default).");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (uint)raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the <c>ServerHandle</c> from a Register reply, throwing a
|
||||||
|
/// descriptive <see cref="MxGatewayException"/> when the typed
|
||||||
|
/// <c>Register</c> payload is absent on an otherwise-successful reply.
|
||||||
|
/// The typed sub-message is the contract for the Register command, so
|
||||||
|
/// its absence must not silently fall through to
|
||||||
|
/// <c>ReturnValue.Int32Value</c> (which would be <c>0</c> for an empty
|
||||||
|
/// reply, driving the rest of the bench against an invalid handle).
|
||||||
|
/// Resolves Client.Dotnet-019.
|
||||||
|
/// </summary>
|
||||||
|
private static int RequireRegisterServerHandle(MxCommandReply reply, string sessionId)
|
||||||
|
{
|
||||||
|
if (reply.Register is null)
|
||||||
|
{
|
||||||
|
throw new MxGatewayException(
|
||||||
|
$"Gateway reply for Register on session '{sessionId}' (correlation '{reply.CorrelationId}') "
|
||||||
|
+ "succeeded but is missing the typed 'register' payload required to read ServerHandle.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.Register.ServerHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-language stress benchmark for ReadBulk. Opens its own session,
|
||||||
|
/// subscribes to N tags so the worker's MxAccessValueCache populates from
|
||||||
|
/// real OnDataChange events, then hammers ReadBulk in a tight in-process
|
||||||
|
/// loop with per-call Stopwatch timing. Emits a single JSON object on
|
||||||
|
/// stdout that the scripts/bench-read-bulk.ps1 driver collates across
|
||||||
|
/// all five language clients.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<int> BenchReadBulkAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int durationSeconds = arguments.GetInt32("duration-seconds", 30);
|
||||||
|
int warmupSeconds = arguments.GetInt32("warmup-seconds", 3);
|
||||||
|
int bulkSize = arguments.GetInt32("bulk-size", 6);
|
||||||
|
int tagStart = arguments.GetInt32("tag-start", 1);
|
||||||
|
string tagPrefix = arguments.GetOptional("tag-prefix") ?? "TestMachine_";
|
||||||
|
string tagAttribute = arguments.GetOptional("tag-attribute") ?? "TestChangingInt";
|
||||||
|
uint timeoutMs = ParseTimeoutMs(arguments, defaultValue: 1500);
|
||||||
|
string clientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-bench";
|
||||||
|
|
||||||
|
string[] tags = new string[bulkSize];
|
||||||
|
for (int i = 0; i < bulkSize; i++)
|
||||||
|
{
|
||||||
|
// TestMachine_NNN.<attribute>, three-digit machine numbers matching
|
||||||
|
// the existing e2e tag-discovery convention.
|
||||||
|
tags[i] = $"{tagPrefix}{(tagStart + i):D3}.{tagAttribute}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open + register + subscribe-bulk so the cache populates before the
|
||||||
|
// measurement window opens.
|
||||||
|
OpenSessionReply openReply = await client.OpenSessionAsync(
|
||||||
|
new OpenSessionRequest { ClientSessionName = clientName, ClientCorrelationId = CreateCorrelationId() },
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
string sessionId = openReply.SessionId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MxCommandReply registerReply = await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(sessionId, new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = clientName },
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
int serverHandle = RequireRegisterServerHandle(registerReply, sessionId);
|
||||||
|
|
||||||
|
SubscribeBulkCommand subscribe = new() { ServerHandle = serverHandle };
|
||||||
|
subscribe.TagAddresses.Add(tags);
|
||||||
|
MxCommandReply subscribeReply = await InvokeAndEnsureAsync(
|
||||||
|
client,
|
||||||
|
CreateCommandRequest(sessionId, new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.SubscribeBulk,
|
||||||
|
SubscribeBulk = subscribe,
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
int[] itemHandles = subscribeReply.SubscribeBulk?.Results
|
||||||
|
.Where(r => r.WasSuccessful)
|
||||||
|
.Select(r => r.ItemHandle)
|
||||||
|
.ToArray() ?? [];
|
||||||
|
|
||||||
|
// Warm-up: drive the same call shape so the JIT / connection
|
||||||
|
// pipelines settle before the measurement window opens.
|
||||||
|
DateTime warmupDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(warmupSeconds);
|
||||||
|
ReadBulkCommand readBulkCommand = new()
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
TimeoutMs = timeoutMs,
|
||||||
|
};
|
||||||
|
readBulkCommand.TagAddresses.Add(tags);
|
||||||
|
MxCommand readBulkMxCommand = new() { Kind = MxCommandKind.ReadBulk, ReadBulk = readBulkCommand };
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < warmupDeadline)
|
||||||
|
{
|
||||||
|
_ = await client.InvokeAsync(
|
||||||
|
CreateCommandRequest(sessionId, readBulkMxCommand),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady state — capture per-call wall latency with a high-res
|
||||||
|
// Stopwatch so the resolution is sub-millisecond on modern Windows.
|
||||||
|
List<double> latencyMillis = new(capacity: 65536);
|
||||||
|
long totalReadResults = 0;
|
||||||
|
long cachedReadResults = 0;
|
||||||
|
int successfulCalls = 0;
|
||||||
|
int failedCalls = 0;
|
||||||
|
DateTime steadyDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(durationSeconds);
|
||||||
|
DateTime steadyStart = DateTime.UtcNow;
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < steadyDeadline)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Stopwatch sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
MxCommandReply reply;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reply = await client.InvokeAsync(
|
||||||
|
CreateCommandRequest(sessionId, readBulkMxCommand),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
sw.Stop();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Client.Dotnet-020: never swallow OperationCanceledException
|
||||||
|
// here. A bare `catch` would let Ctrl+C / parent CTS /
|
||||||
|
// wall-clock timeouts keep spinning until --duration-seconds
|
||||||
|
// elapsed, burning CPU and skewing the p99/max latency numbers
|
||||||
|
// with hundreds of immediate-OCE iterations.
|
||||||
|
sw.Stop();
|
||||||
|
failedCalls++;
|
||||||
|
latencyMillis.Add(sw.Elapsed.TotalMilliseconds);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
latencyMillis.Add(sw.Elapsed.TotalMilliseconds);
|
||||||
|
if (reply.ProtocolStatus?.Code != ProtocolStatusCode.Ok)
|
||||||
|
{
|
||||||
|
failedCalls++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
successfulCalls++;
|
||||||
|
if (reply.ReadBulk is not null)
|
||||||
|
{
|
||||||
|
foreach (BulkReadResult r in reply.ReadBulk.Results)
|
||||||
|
{
|
||||||
|
totalReadResults++;
|
||||||
|
if (r.WasCached)
|
||||||
|
{
|
||||||
|
cachedReadResults++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double steadyElapsedSeconds = (DateTime.UtcNow - steadyStart).TotalSeconds;
|
||||||
|
|
||||||
|
if (itemHandles.Length > 0)
|
||||||
|
{
|
||||||
|
UnsubscribeBulkCommand unsubscribe = new() { ServerHandle = serverHandle };
|
||||||
|
unsubscribe.ItemHandles.Add(itemHandles);
|
||||||
|
_ = await client.InvokeAsync(
|
||||||
|
CreateCommandRequest(sessionId, new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.UnsubscribeBulk,
|
||||||
|
UnsubscribeBulk = unsubscribe,
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalCalls = successfulCalls + failedCalls;
|
||||||
|
double callsPerSecond = steadyElapsedSeconds > 0
|
||||||
|
? totalCalls / steadyElapsedSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
object stats = new
|
||||||
|
{
|
||||||
|
language = "dotnet",
|
||||||
|
command = "bench-read-bulk",
|
||||||
|
endpoint = arguments.GetOptional("endpoint") ?? "(default)",
|
||||||
|
clientName,
|
||||||
|
bulkSize,
|
||||||
|
durationSeconds,
|
||||||
|
warmupSeconds,
|
||||||
|
durationMs = (long)(steadyElapsedSeconds * 1000),
|
||||||
|
tags,
|
||||||
|
totalCalls,
|
||||||
|
successfulCalls,
|
||||||
|
failedCalls,
|
||||||
|
totalReadResults,
|
||||||
|
cachedReadResults,
|
||||||
|
callsPerSecond = Math.Round(callsPerSecond, 2),
|
||||||
|
latencyMs = new
|
||||||
|
{
|
||||||
|
p50 = Percentile(latencyMillis, 0.50),
|
||||||
|
p95 = Percentile(latencyMillis, 0.95),
|
||||||
|
p99 = Percentile(latencyMillis, 0.99),
|
||||||
|
max = latencyMillis.Count > 0 ? Math.Round(latencyMillis.Max(), 3) : 0,
|
||||||
|
mean = latencyMillis.Count > 0 ? Math.Round(latencyMillis.Average(), 3) : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(stats, JsonOptions));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.CloseSessionAsync(
|
||||||
|
new CloseSessionRequest { SessionId = sessionId, ClientCorrelationId = CreateCorrelationId() },
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing the session is best-effort — never let it mask a real bench error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the requested percentile from an unsorted latency sample using
|
||||||
|
/// nearest-rank with linear interpolation. Rounds to 3 decimal places to
|
||||||
|
/// match the JSON schema the PS driver collates.
|
||||||
|
/// </summary>
|
||||||
|
private static double Percentile(IReadOnlyList<double> sample, double quantile)
|
||||||
|
{
|
||||||
|
if (sample.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double[] sorted = sample.ToArray();
|
||||||
|
Array.Sort(sorted);
|
||||||
|
if (sorted.Length == 1)
|
||||||
|
{
|
||||||
|
return Math.Round(sorted[0], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
double rank = quantile * (sorted.Length - 1);
|
||||||
|
int lower = (int)Math.Floor(rank);
|
||||||
|
int upper = (int)Math.Ceiling(rank);
|
||||||
|
double fraction = rank - lower;
|
||||||
|
double value = sorted[lower] + (sorted[upper] - sorted[lower]) * fraction;
|
||||||
|
return Math.Round(value, 3);
|
||||||
|
}
|
||||||
|
|
||||||
private static Task<int> WriteAsync(
|
private static Task<int> WriteAsync(
|
||||||
CliArguments arguments,
|
CliArguments arguments,
|
||||||
IMxGatewayCliClient client,
|
IMxGatewayCliClient client,
|
||||||
@@ -447,29 +1049,37 @@ public static class MxGatewayClientCli
|
|||||||
AfterWorkerSequence = arguments.GetUInt64("after-worker-sequence", 0),
|
AfterWorkerSequence = arguments.GetUInt64("after-worker-sequence", 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
await foreach (MxEvent gatewayEvent in client.StreamEventsAsync(request, cancellationToken)
|
try
|
||||||
.WithCancellation(cancellationToken)
|
|
||||||
.ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
if (jsonLines)
|
await foreach (MxEvent gatewayEvent in client.StreamEventsAsync(request, cancellationToken)
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
if (jsonLines)
|
||||||
}
|
{
|
||||||
else if (json)
|
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||||
{
|
}
|
||||||
events.Add(gatewayEvent);
|
else if (json)
|
||||||
}
|
{
|
||||||
else
|
events.Add(gatewayEvent);
|
||||||
{
|
}
|
||||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
else
|
||||||
}
|
{
|
||||||
|
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||||
|
}
|
||||||
|
|
||||||
eventCount++;
|
eventCount++;
|
||||||
if (maxEvents > 0 && eventCount >= maxEvents)
|
if (maxEvents > 0 && eventCount >= maxEvents)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Client.Dotnet-017: graceful end-of-window completion mode for a
|
||||||
|
// finite-window event collector. Emit aggregate JSON below and exit 0.
|
||||||
|
}
|
||||||
|
|
||||||
if (json && !jsonLines)
|
if (json && !jsonLines)
|
||||||
{
|
{
|
||||||
@@ -481,6 +1091,124 @@ public static class MxGatewayClientCli
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<int> StreamAlarmsAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
uint maxEvents = arguments.GetUInt32("max-events", 0);
|
||||||
|
bool json = arguments.HasFlag("json");
|
||||||
|
bool jsonLines = arguments.HasFlag("jsonl");
|
||||||
|
if (json && !jsonLines && maxEvents is 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("--json stream-alarms requires --max-events to bound aggregate output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxEvents > MaxAggregateEvents)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"--max-events cannot exceed {MaxAggregateEvents}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = json && !jsonLines
|
||||||
|
? new List<AlarmFeedMessage>(checked((int)maxEvents))
|
||||||
|
: [];
|
||||||
|
uint messageCount = 0;
|
||||||
|
var request = new StreamAlarmsRequest
|
||||||
|
{
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
AlarmFilterPrefix = arguments.GetOptional("filter-prefix") ?? string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (AlarmFeedMessage feedMessage in client.StreamAlarmsAsync(request, cancellationToken)
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (jsonLines)
|
||||||
|
{
|
||||||
|
output.WriteLine(ProtobufJsonFormatter.Format(feedMessage));
|
||||||
|
}
|
||||||
|
else if (json)
|
||||||
|
{
|
||||||
|
messages.Add(feedMessage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
output.WriteLine(FormatAlarmFeedMessage(feedMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
messageCount++;
|
||||||
|
if (maxEvents > 0 && messageCount >= maxEvents)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Mirrors stream-events (Client.Dotnet-017): the supplied token covers
|
||||||
|
// the user's --timeout wall-clock budget and external Ctrl+C / parent
|
||||||
|
// CTS cancellation. All are graceful completion modes for a
|
||||||
|
// finite-window alarm-feed collector: emit what arrived and exit 0.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json && !jsonLines)
|
||||||
|
{
|
||||||
|
output.WriteLine(JsonSerializer.Serialize(
|
||||||
|
new { alarms = messages.Select(AlarmFeedMessageToJsonElement).ToArray() },
|
||||||
|
JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<int> AcknowledgeAlarmAsync(
|
||||||
|
CliArguments arguments,
|
||||||
|
IMxGatewayCliClient client,
|
||||||
|
TextWriter output,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var request = new AcknowledgeAlarmRequest
|
||||||
|
{
|
||||||
|
ClientCorrelationId = CreateCorrelationId(),
|
||||||
|
AlarmFullReference = arguments.GetRequired("reference"),
|
||||||
|
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
||||||
|
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
return WriteReplyAsync(
|
||||||
|
client.AcknowledgeAlarmAsync(request, cancellationToken),
|
||||||
|
arguments,
|
||||||
|
output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders one <see cref="AlarmFeedMessage"/> for the human-readable
|
||||||
|
/// (non-JSON) stream-alarms output, distinguishing the <c>payload</c> oneof
|
||||||
|
/// arms: a snapshot active alarm, the snapshot-complete sentinel, or a live
|
||||||
|
/// transition.
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatAlarmFeedMessage(AlarmFeedMessage feedMessage)
|
||||||
|
{
|
||||||
|
return feedMessage.PayloadCase switch
|
||||||
|
{
|
||||||
|
AlarmFeedMessage.PayloadOneofCase.ActiveAlarm =>
|
||||||
|
$"active-alarm {ProtobufJsonFormatter.Format(feedMessage.ActiveAlarm)}",
|
||||||
|
AlarmFeedMessage.PayloadOneofCase.SnapshotComplete =>
|
||||||
|
$"snapshot-complete {feedMessage.SnapshotComplete}",
|
||||||
|
AlarmFeedMessage.PayloadOneofCase.Transition =>
|
||||||
|
$"transition {ProtobufJsonFormatter.Format(feedMessage.Transition)}",
|
||||||
|
_ => $"unknown-payload {feedMessage.PayloadCase}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement AlarmFeedMessageToJsonElement(AlarmFeedMessage feedMessage)
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse(ProtobufJsonFormatter.Format(feedMessage)).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<int> SmokeAsync(
|
private static async Task<int> SmokeAsync(
|
||||||
CliArguments arguments,
|
CliArguments arguments,
|
||||||
IMxGatewayCliClient client,
|
IMxGatewayCliClient client,
|
||||||
@@ -755,11 +1483,15 @@ public static class MxGatewayClientCli
|
|||||||
|
|
||||||
private static MxValue ParseValue(CliArguments arguments)
|
private static MxValue ParseValue(CliArguments arguments)
|
||||||
{
|
{
|
||||||
string type = arguments.GetRequired("type").ToLowerInvariant();
|
return ParseValue(arguments.GetRequired("type"), arguments.GetRequired("value"));
|
||||||
string value = arguments.GetRequired("value");
|
}
|
||||||
|
|
||||||
|
private static MxValue ParseValue(string type, string value)
|
||||||
|
{
|
||||||
|
string normalisedType = type.ToLowerInvariant();
|
||||||
string[] values = value.Split(',', StringSplitOptions.TrimEntries);
|
string[] values = value.Split(',', StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
return type switch
|
return normalisedType switch
|
||||||
{
|
{
|
||||||
"bool" or "boolean" => bool.Parse(value).ToMxValue(),
|
"bool" or "boolean" => bool.Parse(value).ToMxValue(),
|
||||||
"bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(),
|
"bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(),
|
||||||
@@ -778,7 +1510,7 @@ public static class MxGatewayClientCli
|
|||||||
.Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal))
|
.Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal))
|
||||||
.ToArray()
|
.ToArray()
|
||||||
.ToMxValue(),
|
.ToMxValue(),
|
||||||
_ => throw new ArgumentException($"Unsupported MX value type '{type}'."),
|
_ => throw new ArgumentException($"Unsupported MX value type '{normalisedType}'."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,7 +1721,15 @@ public static class MxGatewayClientCli
|
|||||||
or "advise"
|
or "advise"
|
||||||
or "subscribe-bulk"
|
or "subscribe-bulk"
|
||||||
or "unsubscribe-bulk"
|
or "unsubscribe-bulk"
|
||||||
|
or "read-bulk"
|
||||||
|
or "write-bulk"
|
||||||
|
or "write2-bulk"
|
||||||
|
or "write-secured-bulk"
|
||||||
|
or "write-secured2-bulk"
|
||||||
|
or "bench-read-bulk"
|
||||||
or "stream-events"
|
or "stream-events"
|
||||||
|
or "stream-alarms"
|
||||||
|
or "acknowledge-alarm"
|
||||||
or "write"
|
or "write"
|
||||||
or "write2"
|
or "write2"
|
||||||
or "smoke"
|
or "smoke"
|
||||||
@@ -1032,6 +1772,7 @@ public static class MxGatewayClientCli
|
|||||||
|
|
||||||
private static void WriteUsage(TextWriter writer)
|
private static void WriteUsage(TextWriter writer)
|
||||||
{
|
{
|
||||||
|
writer.WriteLine("mxgw-dotnet batch (reads commands from stdin; writes output + __MXGW_BATCH_EOR__ after each)");
|
||||||
writer.WriteLine("mxgw-dotnet version [--json]");
|
writer.WriteLine("mxgw-dotnet version [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet ping --session-id <id> [--json]");
|
writer.WriteLine("mxgw-dotnet ping --session-id <id> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet open-session [--client-name <name>] [--json]");
|
writer.WriteLine("mxgw-dotnet open-session [--client-name <name>] [--json]");
|
||||||
@@ -1041,7 +1782,15 @@ public static class MxGatewayClientCli
|
|||||||
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet write-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--user-id <n>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet write2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> [--timestamp <iso>] [--user-id <n>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet write-secured-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet write-secured2-bulk --session-id <id> --server-handle <n> --item-handles <n,n> --type <type> --values <v,v> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
|
||||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||||
|
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
||||||
|
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||||
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
using MxGateway.Client.Cli;
|
using ZB.MOM.WW.MxGateway.Client.Cli;
|
||||||
|
|
||||||
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
|
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-2
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fake Galaxy Repository client transport for testing.
|
/// Fake Galaxy Repository client transport for testing.
|
||||||
@@ -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>
|
||||||
+39
-35
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fake implementation of IMxGatewayClientTransport for testing.
|
/// Fake implementation of IMxGatewayClientTransport for testing.
|
||||||
@@ -51,6 +51,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
|
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of captured StreamAlarmsAsync calls.
|
||||||
|
/// </summary>
|
||||||
|
public List<(StreamAlarmsRequest Request, CallOptions CallOptions)> StreamAlarmsCalls { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
|
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -58,6 +63,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
|
|
||||||
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
|
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
|
||||||
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
|
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
|
||||||
|
private readonly List<AlarmFeedMessage> _alarmFeedMessages = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the reply to return from OpenSessionAsync.
|
/// Gets or sets the reply to return from OpenSessionAsync.
|
||||||
@@ -91,19 +97,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> CloseSessionExceptions { get; } = new();
|
public Queue<Exception> CloseSessionExceptions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether thrown <see cref="RpcException"/>s are mapped
|
|
||||||
/// to <see cref="MxGatewayException"/> the way the production gRPC transport does. Lets
|
|
||||||
/// retry tests exercise the wrapped-exception predicate branch that runs in production.
|
|
||||||
/// </summary>
|
|
||||||
public bool MapTransportExceptions { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an optional hook awaited inside CloseSessionAsync after the call is
|
|
||||||
/// recorded; lets tests pause a close mid-flight to observe concurrent dispose.
|
|
||||||
/// </summary>
|
|
||||||
public Func<Task>? CloseSessionHook { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the queue of exceptions to throw from InvokeAsync.
|
/// Gets the queue of exceptions to throw from InvokeAsync.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -121,7 +114,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
OpenSessionCalls.Add((request, callOptions));
|
OpenSessionCalls.Add((request, callOptions));
|
||||||
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
|
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
|
||||||
{
|
{
|
||||||
throw Translate(exception, callOptions);
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(OpenSessionReply);
|
return Task.FromResult(OpenSessionReply);
|
||||||
@@ -132,23 +125,17 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The CloseSessionRequest to process.</param>
|
/// <param name="request">The CloseSessionRequest to process.</param>
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public async Task<CloseSessionReply> CloseSessionAsync(
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
CloseSessionRequest request,
|
CloseSessionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
{
|
{
|
||||||
CloseSessionCalls.Add((request, callOptions));
|
CloseSessionCalls.Add((request, callOptions));
|
||||||
|
|
||||||
if (CloseSessionHook is not null)
|
|
||||||
{
|
|
||||||
await CloseSessionHook().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
|
||||||
{
|
{
|
||||||
throw Translate(exception, callOptions);
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
return CloseSessionReply;
|
return Task.FromResult(CloseSessionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -163,7 +150,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
InvokeCalls.Add((request, callOptions));
|
InvokeCalls.Add((request, callOptions));
|
||||||
if (InvokeExceptions.TryDequeue(out Exception? exception))
|
if (InvokeExceptions.TryDequeue(out Exception? exception))
|
||||||
{
|
{
|
||||||
throw Translate(exception, callOptions);
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(_invokeReplies.Dequeue());
|
return Task.FromResult(_invokeReplies.Dequeue());
|
||||||
@@ -209,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)
|
||||||
@@ -223,7 +212,6 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
? _acknowledgeReplies.Dequeue()
|
? _acknowledgeReplies.Dequeue()
|
||||||
: new AcknowledgeAlarmReply
|
: new AcknowledgeAlarmReply
|
||||||
{
|
{
|
||||||
SessionId = request.SessionId,
|
|
||||||
CorrelationId = request.ClientCorrelationId,
|
CorrelationId = request.ClientCorrelationId,
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
|
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
|
||||||
@@ -233,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)
|
||||||
@@ -248,28 +238,42 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a queued exception the way the production gRPC transport does when
|
/// Records the stream-alarms call and yields each enqueued feed message.
|
||||||
/// <see cref="MapTransportExceptions"/> is set; otherwise returns it unchanged.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Exception Translate(Exception exception, CallOptions callOptions)
|
/// <param name="request">The stream alarms request.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
{
|
{
|
||||||
if (MapTransportExceptions && exception is RpcException rpcException)
|
StreamAlarmsCalls.Add((request, callOptions));
|
||||||
{
|
|
||||||
return RpcExceptionMapper.Map(rpcException, callOptions.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return exception;
|
foreach (AlarmFeedMessage message in _alarmFeedMessages)
|
||||||
|
{
|
||||||
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
_alarmFeedMessages.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,8 +1,8 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class GalaxyRepositoryClientTests
|
public sealed class GalaxyRepositoryClientTests
|
||||||
{
|
{
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxCommandReplyExtensionsTests
|
public sealed class MxCommandReplyExtensionsTests
|
||||||
{
|
{
|
||||||
+8
-6
@@ -1,8 +1,8 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
|
/// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
|
||||||
@@ -11,13 +11,13 @@ namespace 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()
|
||||||
{
|
{
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
|
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
CorrelationId = "corr-1",
|
CorrelationId = "corr-1",
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
Status = new MxStatusProxy
|
Status = new MxStatusProxy
|
||||||
@@ -31,7 +31,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
|
|
||||||
AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
ClientCorrelationId = "corr-1",
|
ClientCorrelationId = "corr-1",
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = "investigating",
|
Comment = "investigating",
|
||||||
@@ -48,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()
|
||||||
{
|
{
|
||||||
@@ -64,7 +64,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
client.AcknowledgeAlarmAsync(
|
client.AcknowledgeAlarmAsync(
|
||||||
new AcknowledgeAlarmRequest
|
new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = string.Empty,
|
Comment = string.Empty,
|
||||||
OperatorUser = "alice",
|
OperatorUser = "alice",
|
||||||
@@ -72,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()
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,6 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||||
{
|
{
|
||||||
SessionId = "session-fixture",
|
|
||||||
AlarmFullReference = "Tank01.Level.HiHi",
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
Comment = string.Empty,
|
Comment = string.Empty,
|
||||||
OperatorUser = "alice",
|
OperatorUser = "alice",
|
||||||
@@ -97,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()
|
||||||
{
|
{
|
||||||
@@ -121,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()
|
||||||
{
|
{
|
||||||
@@ -140,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()
|
||||||
{
|
{
|
||||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxGatewayClientContractInfoTests
|
public sealed class MxGatewayClientContractInfoTests
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxGatewayClientOptionsTests
|
public sealed class MxGatewayClientOptionsTests
|
||||||
{
|
{
|
||||||
+2
-77
@@ -1,7 +1,7 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
|
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
|
||||||
public sealed class MxGatewayClientSessionTests
|
public sealed class MxGatewayClientSessionTests
|
||||||
@@ -231,52 +231,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
Assert.Equal("session-fixture", call.Request.SessionId);
|
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that disposing a session while other callers are concurrently inside
|
|
||||||
/// <see cref="MxGatewaySession.CloseAsync"/> — one holding the close lock and one
|
|
||||||
/// parked on it — never throws <see cref="ObjectDisposedException"/> into those
|
|
||||||
/// callers. The close lock must outlive every pending close.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task DisposeAsync_DoesNotRaceConcurrentCloseAsync()
|
|
||||||
{
|
|
||||||
for (int iteration = 0; iteration < 100; iteration++)
|
|
||||||
{
|
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
|
||||||
using SemaphoreSlim firstCloseEntered = new(0, 1);
|
|
||||||
using SemaphoreSlim releaseFirstClose = new(0, 1);
|
|
||||||
|
|
||||||
// The first CloseAsync to reach the transport parks here while holding the
|
|
||||||
// session's close lock; later callers queue on the lock behind it.
|
|
||||||
transport.CloseSessionHook = async () =>
|
|
||||||
{
|
|
||||||
firstCloseEntered.Release();
|
|
||||||
await releaseFirstClose.WaitAsync().ConfigureAwait(false);
|
|
||||||
transport.CloseSessionHook = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
await using MxGatewayClient client = CreateClient(transport);
|
|
||||||
MxGatewaySession session = await client.OpenSessionAsync();
|
|
||||||
|
|
||||||
// Holder enters CloseAsync, acquires the lock, and parks in the hook.
|
|
||||||
Task holder = Task.Run(() => session.CloseAsync());
|
|
||||||
await firstCloseEntered.WaitAsync();
|
|
||||||
|
|
||||||
// Waiter is parked on the close lock behind the holder.
|
|
||||||
Task waiter = Task.Run(() => session.CloseAsync());
|
|
||||||
|
|
||||||
// DisposeAsync runs concurrently; it must wait out both callers before
|
|
||||||
// disposing the close lock rather than tearing it down underneath them.
|
|
||||||
Task dispose = session.DisposeAsync().AsTask();
|
|
||||||
|
|
||||||
releaseFirstClose.Release();
|
|
||||||
|
|
||||||
await holder;
|
|
||||||
await waiter;
|
|
||||||
await dispose;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
||||||
@@ -301,35 +255,6 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
Assert.Equal(2, transport.InvokeCalls.Count);
|
Assert.Equal(2, transport.InvokeCalls.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that the retry pipeline still retries when the transport maps the raw
|
|
||||||
/// <see cref="RpcException"/> to an <see cref="MxGatewayException"/> before it reaches
|
|
||||||
/// the retry predicate — the wrapped-exception shape that production always produces.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommand_WhenTransportMapsRpcException()
|
|
||||||
{
|
|
||||||
FakeGatewayTransport transport = CreateTransport();
|
|
||||||
transport.MapTransportExceptions = true;
|
|
||||||
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
|
|
||||||
transport.AddInvokeReply(new MxCommandReply
|
|
||||||
{
|
|
||||||
SessionId = "session-fixture",
|
|
||||||
Kind = MxCommandKind.Ping,
|
|
||||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
||||||
});
|
|
||||||
await using MxGatewayClient client = CreateClient(transport);
|
|
||||||
MxGatewaySession session = await client.OpenSessionAsync();
|
|
||||||
|
|
||||||
await session.InvokeAsync(new MxCommandRequest
|
|
||||||
{
|
|
||||||
SessionId = session.SessionId,
|
|
||||||
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Equal(2, transport.InvokeCalls.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxGatewayGeneratedContractTests
|
public sealed class MxGatewayGeneratedContractTests
|
||||||
{
|
{
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxStatusProxyExtensionsTests
|
public sealed class MxStatusProxyExtensionsTests
|
||||||
{
|
{
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client.Tests;
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
public sealed class MxValueExtensionsTests
|
public sealed class MxValueExtensionsTests
|
||||||
{
|
{
|
||||||
+2
-2
@@ -19,8 +19,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
|
||||||
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
|
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client.Cli\ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Solution>
|
||||||
|
<Configurations>
|
||||||
|
<Platform Name="Any CPU" />
|
||||||
|
<Platform Name="x64" />
|
||||||
|
<Platform Name="x86" />
|
||||||
|
</Configurations>
|
||||||
|
<Project Path="../../src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
||||||
|
<Project Path="ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
|
||||||
|
<Project Path="ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj" />
|
||||||
|
<Project Path="ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters and shape options for <see cref="GalaxyRepositoryClient.DiscoverHierarchyAsync(DiscoverHierarchyOptions, System.Threading.CancellationToken)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Hand-written ergonomic wrapper around the generated
|
||||||
|
/// <c>DiscoverHierarchyRequest</c>: lets callers express a Galaxy-browse
|
||||||
|
/// slice with .NET-friendly nullable scalars and collection initializers,
|
||||||
|
/// without touching the protobuf message's <c>oneof root</c> directly.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DiscoverHierarchyOptions
|
||||||
|
{
|
||||||
|
/// <summary>Restrict to the subtree rooted at this Galaxy <c>gobject_id</c>.</summary>
|
||||||
|
public int? RootGobjectId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Restrict to the subtree rooted at the object with this tag name.</summary>
|
||||||
|
public string? RootTagName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Restrict to the subtree rooted at this <c>contained_name</c> path.</summary>
|
||||||
|
public string? RootContainedPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Maximum traversal depth, measured from the chosen root.</summary>
|
||||||
|
public int? MaxDepth { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Restrict to objects whose Galaxy category is in this set.</summary>
|
||||||
|
public IReadOnlyList<int> CategoryIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Restrict to objects 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 objects that bear at least one alarm attribute.</summary>
|
||||||
|
public bool AlarmBearingOnly { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Restrict to objects that have at least one historized attribute.</summary>
|
||||||
|
public bool HistorizedOnly { get; init; }
|
||||||
|
}
|
||||||
+103
-3
@@ -2,14 +2,14 @@ using Google.Protobuf.WellKnownTypes;
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Grpc.Net.Client;
|
using Grpc.Net.Client;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
using Polly;
|
using Polly;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
|
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
|
||||||
@@ -19,6 +19,7 @@ namespace 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;
|
||||||
+47
-6
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// gRPC implementation of IGalaxyRepositoryClientTransport.
|
/// gRPC implementation of IGalaxyRepositoryClientTransport.
|
||||||
@@ -36,7 +36,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,24 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +118,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
throw MapRpcException(exception, effectiveCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return deployEvent;
|
yield return deployEvent;
|
||||||
@@ -115,4 +132,28 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
{
|
{
|
||||||
return WatchDeployEventsAsync(request, callOptions);
|
return WatchDeployEventsAsync(request, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Exception MapRpcException(
|
||||||
|
RpcException exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
|
||||||
|
{
|
||||||
|
return new OperationCanceledException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
exception,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exception.StatusCode switch
|
||||||
|
{
|
||||||
|
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
innerException: exception),
|
||||||
|
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
innerException: exception),
|
||||||
|
_ => new MxGatewayException(exception.Status.Detail, exception),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+74
-8
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// gRPC implementation of IMxGatewayClientTransport.
|
/// gRPC implementation of IMxGatewayClientTransport.
|
||||||
@@ -36,7 +36,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
throw MapRpcException(exception, effectiveCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return gatewayEvent;
|
yield return gatewayEvent;
|
||||||
@@ -129,7 +129,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, callOptions.CancellationToken);
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
throw RpcExceptionMapper.Map(exception, effectiveCancellationToken);
|
throw MapRpcException(exception, effectiveCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return snapshot;
|
yield return snapshot;
|
||||||
@@ -174,4 +174,70 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
{
|
{
|
||||||
return QueryActiveAlarmsAsync(request, callOptions);
|
return QueryActiveAlarmsAsync(request, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
||||||
|
? cancellationToken
|
||||||
|
: callOptions.CancellationToken;
|
||||||
|
|
||||||
|
using AsyncServerStreamingCall<AlarmFeedMessage> call = RawClient.StreamAlarms(request, callOptions);
|
||||||
|
|
||||||
|
IAsyncStreamReader<AlarmFeedMessage> responseStream = call.ResponseStream;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
AlarmFeedMessage? message;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = responseStream.Current;
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw MapRpcException(exception, effectiveCancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> IMxGatewayClientTransport.StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return StreamAlarmsAsync(request, callOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Exception MapRpcException(
|
||||||
|
RpcException exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
|
||||||
|
{
|
||||||
|
return new OperationCanceledException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
exception,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exception.StatusCode switch
|
||||||
|
{
|
||||||
|
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
innerException: exception),
|
||||||
|
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
|
||||||
|
exception.Status.Detail,
|
||||||
|
innerException: exception),
|
||||||
|
_ => new MxGatewayException(exception.Status.Detail, exception),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+9
-2
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto.Galaxy;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
|
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
|
||||||
internal interface IGalaxyRepositoryClientTransport
|
internal interface IGalaxyRepositoryClientTransport
|
||||||
@@ -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>
|
||||||
+13
-2
@@ -1,7 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
internal interface IMxGatewayClientTransport
|
internal interface IMxGatewayClientTransport
|
||||||
{
|
{
|
||||||
@@ -75,4 +75,15 @@ internal interface IMxGatewayClientTransport
|
|||||||
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the gateway's central alarm feed — the current active-alarm
|
||||||
|
/// snapshot followed by live transitions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
|
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
|
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
|
||||||
public sealed class MxAccessException : MxGatewayCommandException
|
public sealed class MxAccessException : MxGatewayCommandException
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
|
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
|
||||||
public static class MxCommandReplyExtensions
|
public static class MxCommandReplyExtensions
|
||||||
+4
-8
@@ -1,7 +1,6 @@
|
|||||||
using Grpc.Core;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
|
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
|
||||||
public sealed class MxGatewayAuthenticationException : MxGatewayException
|
public sealed class MxGatewayAuthenticationException : MxGatewayException
|
||||||
@@ -14,7 +13,6 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
|||||||
/// <param name="hResult">The HResult code, if available.</param>
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
/// <param name="innerException">The underlying exception, if any.</param>
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
|
||||||
public MxGatewayAuthenticationException(
|
public MxGatewayAuthenticationException(
|
||||||
string message,
|
string message,
|
||||||
string? sessionId = null,
|
string? sessionId = null,
|
||||||
@@ -22,8 +20,7 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
|||||||
ProtocolStatus? protocolStatus = null,
|
ProtocolStatus? protocolStatus = null,
|
||||||
int? hResult = null,
|
int? hResult = null,
|
||||||
IReadOnlyList<MxStatusProxy>? statuses = null,
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
Exception? innerException = null,
|
Exception? innerException = null)
|
||||||
StatusCode? statusCode = null)
|
|
||||||
: base(
|
: base(
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -31,8 +28,7 @@ public sealed class MxGatewayAuthenticationException : MxGatewayException
|
|||||||
protocolStatus,
|
protocolStatus,
|
||||||
hResult,
|
hResult,
|
||||||
statuses ?? [],
|
statuses ?? [],
|
||||||
innerException,
|
innerException)
|
||||||
statusCode)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-8
@@ -1,7 +1,6 @@
|
|||||||
using Grpc.Core;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
|
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
|
||||||
public sealed class MxGatewayAuthorizationException : MxGatewayException
|
public sealed class MxGatewayAuthorizationException : MxGatewayException
|
||||||
@@ -14,7 +13,6 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
|||||||
/// <param name="hResult">The HResult code, if available.</param>
|
/// <param name="hResult">The HResult code, if available.</param>
|
||||||
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
/// <param name="statuses">The MXAccess statuses, if available.</param>
|
||||||
/// <param name="innerException">The underlying exception, if any.</param>
|
/// <param name="innerException">The underlying exception, if any.</param>
|
||||||
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
|
||||||
public MxGatewayAuthorizationException(
|
public MxGatewayAuthorizationException(
|
||||||
string message,
|
string message,
|
||||||
string? sessionId = null,
|
string? sessionId = null,
|
||||||
@@ -22,8 +20,7 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
|||||||
ProtocolStatus? protocolStatus = null,
|
ProtocolStatus? protocolStatus = null,
|
||||||
int? hResult = null,
|
int? hResult = null,
|
||||||
IReadOnlyList<MxStatusProxy>? statuses = null,
|
IReadOnlyList<MxStatusProxy>? statuses = null,
|
||||||
Exception? innerException = null,
|
Exception? innerException = null)
|
||||||
StatusCode? statusCode = null)
|
|
||||||
: base(
|
: base(
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -31,8 +28,7 @@ public sealed class MxGatewayAuthorizationException : MxGatewayException
|
|||||||
protocolStatus,
|
protocolStatus,
|
||||||
hResult,
|
hResult,
|
||||||
statuses ?? [],
|
statuses ?? [],
|
||||||
innerException,
|
innerException)
|
||||||
statusCode)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+41
-6
@@ -1,13 +1,13 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Grpc.Net.Client;
|
using Grpc.Net.Client;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using Polly;
|
using Polly;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
||||||
@@ -17,7 +17,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
private readonly GrpcChannel _channel;
|
private readonly GrpcChannel _channel;
|
||||||
private readonly IMxGatewayClientTransport _transport;
|
private readonly IMxGatewayClientTransport _transport;
|
||||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||||
private int _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
|
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
|
||||||
@@ -224,16 +224,39 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches to the gateway's central alarm feed. The stream opens with one
|
||||||
|
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
|
||||||
|
/// ConditionRefresh snapshot), then a single <c>snapshot_complete</c>, then a
|
||||||
|
/// <c>transition</c> for every subsequent raise / acknowledge / clear. Served
|
||||||
|
/// by the gateway's always-on alarm monitor — no worker session is opened, so
|
||||||
|
/// any number of clients may attach. Optionally scoped by alarm-reference
|
||||||
|
/// prefix (<see cref="StreamAlarmsRequest.AlarmFilterPrefix"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token for the stream.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
|
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
|
StreamAlarmsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.StreamAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes the client and releases all resources.
|
/// Disposes the client and releases all resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
if (_disposed)
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
_channel?.Dispose();
|
_channel?.Dispose();
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -292,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()
|
||||||
{
|
{
|
||||||
@@ -312,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;
|
||||||
@@ -327,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;
|
||||||
@@ -334,6 +369,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
|
|
||||||
private void ThrowIfDisposed()
|
private void ThrowIfDisposed()
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-2
@@ -1,15 +1,17 @@
|
|||||||
using MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exposes the protocol versions compiled into this client package.
|
/// Exposes the protocol versions compiled into this client package.
|
||||||
/// </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;
|
||||||
}
|
}
|
||||||
+12
-1
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
||||||
@@ -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>
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
|
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
|
||||||
public sealed class MxGatewayClientRetryOptions
|
public sealed class MxGatewayClientRetryOptions
|
||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using Polly;
|
using Polly;
|
||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
|
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
|
||||||
internal static class MxGatewayClientRetryPolicy
|
internal static class MxGatewayClientRetryPolicy
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
|
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
|
||||||
public class MxGatewayCommandException : MxGatewayException
|
public class MxGatewayCommandException : MxGatewayException
|
||||||
+3
-32
@@ -1,7 +1,6 @@
|
|||||||
using Grpc.Core;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Contracts.Proto;
|
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a gateway RPC call fails or returns an error status.
|
/// Exception thrown when a gateway RPC call fails or returns an error status.
|
||||||
@@ -29,20 +28,6 @@ public class MxGatewayException : Exception
|
|||||||
Statuses = [];
|
Statuses = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the MxGatewayException class carrying the originating
|
|
||||||
/// gRPC status code so callers can distinguish transient from permanent failures.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="message">Diagnostic message describing the failure.</param>
|
|
||||||
/// <param name="statusCode">The gRPC status code reported by the failed call.</param>
|
|
||||||
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
|
||||||
public MxGatewayException(string message, StatusCode statusCode, Exception? innerException)
|
|
||||||
: base(message, innerException)
|
|
||||||
{
|
|
||||||
StatusCode = statusCode;
|
|
||||||
Statuses = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
|
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -53,7 +38,6 @@ public class MxGatewayException : Exception
|
|||||||
/// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param>
|
/// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param>
|
||||||
/// <param name="statuses">List of MXAccess status codes returned by the operation.</param>
|
/// <param name="statuses">List of MXAccess status codes returned by the operation.</param>
|
||||||
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
/// <param name="innerException">Underlying exception that caused this failure.</param>
|
||||||
/// <param name="statusCode">The gRPC status code reported by the failed call, if available.</param>
|
|
||||||
public MxGatewayException(
|
public MxGatewayException(
|
||||||
string message,
|
string message,
|
||||||
string? sessionId,
|
string? sessionId,
|
||||||
@@ -61,8 +45,7 @@ public class MxGatewayException : Exception
|
|||||||
ProtocolStatus? protocolStatus,
|
ProtocolStatus? protocolStatus,
|
||||||
int? hResult,
|
int? hResult,
|
||||||
IReadOnlyList<MxStatusProxy> statuses,
|
IReadOnlyList<MxStatusProxy> statuses,
|
||||||
Exception? innerException = null,
|
Exception? innerException = null)
|
||||||
StatusCode? statusCode = null)
|
|
||||||
: base(message, innerException)
|
: base(message, innerException)
|
||||||
{
|
{
|
||||||
SessionId = sessionId;
|
SessionId = sessionId;
|
||||||
@@ -70,7 +53,6 @@ public class MxGatewayException : Exception
|
|||||||
ProtocolStatus = protocolStatus;
|
ProtocolStatus = protocolStatus;
|
||||||
HResultCode = hResult;
|
HResultCode = hResult;
|
||||||
Statuses = statuses;
|
Statuses = statuses;
|
||||||
StatusCode = statusCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -97,15 +79,4 @@ public class MxGatewayException : Exception
|
|||||||
/// Gets the list of MXAccess status codes returned by the operation.
|
/// Gets the list of MXAccess status codes returned by the operation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the gRPC status code reported by the failed call, if the failure originated
|
|
||||||
/// from a gRPC <see cref="RpcException"/>. <see langword="null"/> when the exception
|
|
||||||
/// was not produced from a gRPC status (for example, a protocol-level reply failure).
|
|
||||||
/// Callers can inspect this to distinguish a transient outage
|
|
||||||
/// (<see cref="Grpc.Core.StatusCode.Unavailable"/>) from a permanent error
|
|
||||||
/// (<see cref="Grpc.Core.StatusCode.InvalidArgument"/>) without downcasting
|
|
||||||
/// <see cref="Exception.InnerException"/>.
|
|
||||||
/// </summary>
|
|
||||||
public StatusCode? StatusCode { get; }
|
|
||||||
}
|
}
|
||||||
+176
-58
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents one gateway-backed MXAccess session.
|
/// Represents one gateway-backed MXAccess session.
|
||||||
@@ -9,10 +9,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly MxGatewayClient _client;
|
private readonly MxGatewayClient _client;
|
||||||
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
private readonly object _disposeGate = new();
|
|
||||||
private CloseSessionReply? _closeReply;
|
private CloseSessionReply? _closeReply;
|
||||||
private int _activeCloseCount;
|
|
||||||
private bool _closeLockDisposed;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new session backed by the given MXAccess gateway client.
|
/// Initializes a new session backed by the given MXAccess gateway client.
|
||||||
@@ -49,42 +46,23 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
return _closeReply;
|
return _closeReply;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register as an in-flight closer under the dispose gate. DisposeAsync waits for
|
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
// _activeCloseCount to drain before disposing the close lock, so the semaphore is
|
|
||||||
// guaranteed to outlive every WaitAsync started here.
|
|
||||||
lock (_disposeGate)
|
|
||||||
{
|
|
||||||
ObjectDisposedException.ThrowIf(_closeLockDisposed, this);
|
|
||||||
_activeCloseCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
if (_closeReply is not null)
|
||||||
try
|
|
||||||
{
|
{
|
||||||
if (_closeReply is not null)
|
|
||||||
{
|
|
||||||
return _closeReply;
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeReply = await _client.CloseSessionRawAsync(
|
|
||||||
new CloseSessionRequest { SessionId = SessionId },
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
return _closeReply;
|
return _closeReply;
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
_closeReply = await _client.CloseSessionRawAsync(
|
||||||
_closeLock.Release();
|
new CloseSessionRequest { SessionId = SessionId },
|
||||||
}
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return _closeReply;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
lock (_disposeGate)
|
_closeLock.Release();
|
||||||
{
|
|
||||||
_activeCloseCount--;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +502,171 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
|
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
|
||||||
|
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
|
||||||
|
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
||||||
|
/// Protocol-level failures still throw via EnsureProtocolSuccess.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="entries">Per-item write entries; each carries the item handle, value, and user id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<WriteBulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
WriteBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteBulk,
|
||||||
|
WriteBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.WriteBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.
|
||||||
|
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
|
||||||
|
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, and user id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<Write2BulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
Write2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write2Bulk,
|
||||||
|
Write2Bulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.Write2Bulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk WriteSecured — sequential MXAccess WriteSecured per entry.
|
||||||
|
/// Credential-sensitive values must never reach logs; the client mirrors
|
||||||
|
/// the single-item WriteSecured redaction contract.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="entries">Per-item write entries; each carries the item handle, value, current user id, and verifier user id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
WriteSecuredBulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteSecuredBulk,
|
||||||
|
WriteSecuredBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.
|
||||||
|
/// Same redaction rules as <see cref="WriteSecuredBulkAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, current user id, and verifier user id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
|
||||||
|
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
WriteSecured2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||||
|
command.Entries.Add(entries);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||||
|
WriteSecured2Bulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.WriteSecured2Bulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk Read — snapshot the current value for each requested tag.
|
||||||
|
/// Returns the cached OnDataChange value when the tag is already advised
|
||||||
|
/// (<c>WasCached = true</c>), otherwise the worker takes the full AddItem +
|
||||||
|
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
|
||||||
|
/// failures (timeout, invalid tag) appear as <see cref="BulkReadResult"/>
|
||||||
|
/// entries with <c>WasSuccessful = false</c>; the call never throws on
|
||||||
|
/// per-tag errors.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
|
/// <param name="tagAddresses">Tag addresses to read (one per result).</param>
|
||||||
|
/// <param name="timeout">Per-call timeout for the snapshot lifecycle path; <see cref="TimeSpan.Zero"/> uses the gateway default.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>One <see cref="BulkReadResult"/> per requested tag, in request order.</returns>
|
||||||
|
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||||
|
int serverHandle,
|
||||||
|
IReadOnlyList<string> tagAddresses,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||||
|
|
||||||
|
ReadBulkCommand command = new()
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
|
||||||
|
};
|
||||||
|
command.TagAddresses.Add(tagAddresses);
|
||||||
|
|
||||||
|
MxCommandReply reply = await InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.ReadBulk,
|
||||||
|
ReadBulk = command,
|
||||||
|
},
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||||
|
return reply.ReadBulk?.Results.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes a value to an item on the MXAccess server.
|
/// Writes a value to an item on the MXAccess server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -680,32 +823,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
lock (_disposeGate)
|
|
||||||
{
|
|
||||||
if (_closeLockDisposed)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await CloseAsync().ConfigureAwait(false);
|
await CloseAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
// Wait for every concurrent CloseAsync caller to leave the close lock before
|
|
||||||
// disposing it; once _closeReply is set those callers return without awaiting.
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
lock (_disposeGate)
|
|
||||||
{
|
|
||||||
if (_activeCloseCount == 0)
|
|
||||||
{
|
|
||||||
_closeLockDisposed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Yield();
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeLock.Dispose();
|
_closeLock.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
|
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
|
||||||
public sealed class MxGatewaySessionException : MxGatewayException
|
public sealed class MxGatewaySessionException : MxGatewayException
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
|
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
|
||||||
public sealed class MxGatewayWorkerException : MxGatewayException
|
public sealed class MxGatewayWorkerException : MxGatewayException
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>Extension methods for MxStatusProxy values.</summary>
|
/// <summary>Extension methods for MxStatusProxy values.</summary>
|
||||||
public static class MxStatusProxyExtensions
|
public static class MxStatusProxyExtensions
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Client;
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates and projects gateway MXAccess values without hiding the raw
|
/// Creates and projects gateway MXAccess values without hiding the raw
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Client.Tests")]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\src\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</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>
|
||||||
@@ -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:
|
||||||
|
|||||||
+110
-7
@@ -75,21 +75,30 @@ 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
|
||||||
returned subscription owns cancellation and exposes `Close` for deterministic
|
returned subscription owns cancellation and exposes `Close` for deterministic
|
||||||
goroutine cleanup. `Events` and `EventsAfter` are a compatibility shim with a
|
goroutine cleanup. Raw protobuf messages remain available through the
|
||||||
bounded internal buffer: if the consumer drains too slowly the buffer fills,
|
|
||||||
the underlying stream is cancelled, and a terminal `EventResult` carrying
|
|
||||||
`ErrEventBufferOverflow` is delivered as the channel's last item before it
|
|
||||||
closes — so a slow consumer can distinguish dropped events from a normal
|
|
||||||
end-of-stream. `SubscribeEvents` blocks instead of dropping, so use it when no
|
|
||||||
events may be lost. Raw protobuf messages remain available through the
|
|
||||||
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
||||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||||
errors preserve the raw reply.
|
errors preserve the raw reply.
|
||||||
|
|
||||||
|
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
|
||||||
|
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
||||||
|
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
|
||||||
|
call returns a `StreamAlarmsClient`; cancel its context to terminate the
|
||||||
|
stream. All three pass straight through to the gateway's central alarm
|
||||||
|
monitor.
|
||||||
|
|
||||||
## Galaxy Repository browse
|
## Galaxy Repository browse
|
||||||
|
|
||||||
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
|
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
|
||||||
@@ -120,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
|
||||||
@@ -212,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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -89,10 +91,26 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
|||||||
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||||
case "unsubscribe-bulk":
|
case "unsubscribe-bulk":
|
||||||
return runUnsubscribeBulk(ctx, args[1:], stdout, stderr)
|
return runUnsubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||||
|
case "read-bulk":
|
||||||
|
return runReadBulk(ctx, args[1:], stdout, stderr)
|
||||||
|
case "write-bulk":
|
||||||
|
return runWriteBulk(ctx, args[1:], stdout, stderr)
|
||||||
|
case "write2-bulk":
|
||||||
|
return runWrite2Bulk(ctx, args[1:], stdout, stderr)
|
||||||
|
case "write-secured-bulk":
|
||||||
|
return runWriteSecuredBulk(ctx, args[1:], stdout, stderr)
|
||||||
|
case "write-secured2-bulk":
|
||||||
|
return runWriteSecured2Bulk(ctx, args[1:], stdout, stderr)
|
||||||
|
case "bench-read-bulk":
|
||||||
|
return runBenchReadBulk(ctx, args[1:], stdout, stderr)
|
||||||
case "write":
|
case "write":
|
||||||
return runWrite(ctx, args[1:], stdout, stderr)
|
return runWrite(ctx, args[1:], stdout, stderr)
|
||||||
case "stream-events":
|
case "stream-events":
|
||||||
return runStreamEvents(ctx, args[1:], stdout, stderr)
|
return runStreamEvents(ctx, args[1:], stdout, stderr)
|
||||||
|
case "stream-alarms":
|
||||||
|
return runStreamAlarms(ctx, args[1:], stdout, stderr)
|
||||||
|
case "acknowledge-alarm":
|
||||||
|
return runAcknowledgeAlarm(ctx, args[1:], stdout, stderr)
|
||||||
case "smoke":
|
case "smoke":
|
||||||
return runSmoke(ctx, args[1:], stdout, stderr)
|
return runSmoke(ctx, args[1:], stdout, stderr)
|
||||||
case "galaxy-test-connection":
|
case "galaxy-test-connection":
|
||||||
@@ -103,6 +121,8 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
|||||||
return runGalaxyDiscover(ctx, args[1:], stdout, stderr)
|
return runGalaxyDiscover(ctx, args[1:], stdout, stderr)
|
||||||
case "galaxy-watch":
|
case "galaxy-watch":
|
||||||
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
|
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
|
||||||
|
case "batch":
|
||||||
|
return runBatch(ctx, os.Stdin, stdout, stderr)
|
||||||
default:
|
default:
|
||||||
writeUsage(stderr)
|
writeUsage(stderr)
|
||||||
return fmt.Errorf("unknown command %q", args[0])
|
return fmt.Errorf("unknown command %q", args[0])
|
||||||
@@ -331,11 +351,39 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
|
|||||||
return errors.New("session-id and item-handles are required")
|
return errors.New("session-id and item-handles are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
handles, err := parseInt32List(*itemHandles)
|
handles, err := parseInt32List(*itemHandles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
|
||||||
|
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("read-bulk", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
items := flags.String("items", "", "comma-separated tag addresses")
|
||||||
|
timeoutMs := flags.Int("timeout-ms", 0, "per-tag snapshot timeout in milliseconds (0 = worker default)")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" || *items == "" {
|
||||||
|
return errors.New("session-id and items are required")
|
||||||
|
}
|
||||||
|
|
||||||
client, options, err := dialForCommand(ctx, common)
|
client, options, err := dialForCommand(ctx, common)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -343,8 +391,342 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
|
|||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
|
results, err := session.ReadBulk(ctx, int32(*serverHandle), parseStringList(*items), time.Duration(*timeoutMs)*time.Millisecond)
|
||||||
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
|
return writeReadBulkOutput(stdout, *jsonOutput, "read-bulk", options, results, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWriteBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-bulk", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWrite2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
return runWriteBulkVariant(ctx, args, stdout, stderr, "write2-bulk", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWriteSecuredBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured-bulk", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured2-bulk", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
|
||||||
|
// the four bulk-write families. The variant is derived from command alone;
|
||||||
|
// withTimestamp adds a --timestamp-value flag. To keep wrong-variant flags
|
||||||
|
// from silently no-op'ing, secured-only flags (-current-user-id /
|
||||||
|
// -verifier-user-id) are only registered for the secured variants, and
|
||||||
|
// -user-id only for the non-secured Write/Write2 variants — a wrong-variant
|
||||||
|
// flag then surfaces as a clean "flag provided but not defined" error.
|
||||||
|
func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.Writer, command string, withTimestamp bool) error {
|
||||||
|
secured := command == "write-secured-bulk" || command == "write-secured2-bulk"
|
||||||
|
|
||||||
|
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
|
||||||
|
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
|
||||||
|
values := flags.String("values", "", "comma-separated values (one per item handle)")
|
||||||
|
var (
|
||||||
|
userID *int
|
||||||
|
currentUserID *int
|
||||||
|
verifierUserID *int
|
||||||
|
)
|
||||||
|
if secured {
|
||||||
|
currentUserID = flags.Int("current-user-id", 0, "MXAccess current user id (Secured variants)")
|
||||||
|
verifierUserID = flags.Int("verifier-user-id", 0, "MXAccess verifier user id (Secured variants)")
|
||||||
|
} else {
|
||||||
|
userID = flags.Int("user-id", 0, "MXAccess user id (Write/Write2 variants)")
|
||||||
|
}
|
||||||
|
timestampValue := flags.String("timestamp-value", "", "RFC 3339 timestamp shared across all entries (Write2/WriteSecured2 variants)")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" || *itemHandles == "" || *values == "" {
|
||||||
|
return errors.New("session-id, item-handles, and values are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
handles, err := parseInt32List(*itemHandles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
valueTexts := parseStringList(*values)
|
||||||
|
if len(handles) != len(valueTexts) {
|
||||||
|
return fmt.Errorf("item-handles count (%d) does not match values count (%d)", len(handles), len(valueTexts))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedValues := make([]*mxgateway.MxValue, len(handles))
|
||||||
|
for i, text := range valueTexts {
|
||||||
|
v, err := parseValue(*valueType, text)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("entry %d: %w", i, err)
|
||||||
|
}
|
||||||
|
parsedValues[i] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
var tsValue *mxgateway.MxValue
|
||||||
|
if withTimestamp {
|
||||||
|
if *timestampValue == "" {
|
||||||
|
return errors.New("timestamp-value is required for write2/write-secured2 bulk variants")
|
||||||
|
}
|
||||||
|
parsed, err := parseRfc3339Timestamp(*timestampValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tsValue = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
|
||||||
|
var results []*mxgateway.BulkWriteResult
|
||||||
|
switch command {
|
||||||
|
case "write-bulk":
|
||||||
|
entries := make([]*mxgateway.WriteBulkEntry, len(handles))
|
||||||
|
for i := range handles {
|
||||||
|
entries[i] = &mxgateway.WriteBulkEntry{ItemHandle: handles[i], Value: parsedValues[i], UserId: int32(*userID)}
|
||||||
|
}
|
||||||
|
results, err = session.WriteBulk(ctx, int32(*serverHandle), entries)
|
||||||
|
case "write2-bulk":
|
||||||
|
entries := make([]*mxgateway.Write2BulkEntry, len(handles))
|
||||||
|
for i := range handles {
|
||||||
|
entries[i] = &mxgateway.Write2BulkEntry{ItemHandle: handles[i], Value: parsedValues[i], TimestampValue: tsValue, UserId: int32(*userID)}
|
||||||
|
}
|
||||||
|
results, err = session.Write2Bulk(ctx, int32(*serverHandle), entries)
|
||||||
|
case "write-secured-bulk":
|
||||||
|
entries := make([]*mxgateway.WriteSecuredBulkEntry, len(handles))
|
||||||
|
for i := range handles {
|
||||||
|
entries[i] = &mxgateway.WriteSecuredBulkEntry{
|
||||||
|
ItemHandle: handles[i],
|
||||||
|
Value: parsedValues[i],
|
||||||
|
CurrentUserId: int32(*currentUserID),
|
||||||
|
VerifierUserId: int32(*verifierUserID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results, err = session.WriteSecuredBulk(ctx, int32(*serverHandle), entries)
|
||||||
|
case "write-secured2-bulk":
|
||||||
|
entries := make([]*mxgateway.WriteSecured2BulkEntry, len(handles))
|
||||||
|
for i := range handles {
|
||||||
|
entries[i] = &mxgateway.WriteSecured2BulkEntry{
|
||||||
|
ItemHandle: handles[i],
|
||||||
|
Value: parsedValues[i],
|
||||||
|
TimestampValue: tsValue,
|
||||||
|
CurrentUserId: int32(*currentUserID),
|
||||||
|
VerifierUserId: int32(*verifierUserID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results, err = session.WriteSecured2Bulk(ctx, int32(*serverHandle), entries)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported bulk write command %q", command)
|
||||||
|
}
|
||||||
|
return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRfc3339Timestamp parses an RFC 3339 timestamp and returns the
|
||||||
|
// MxValue protobuf representation used for the timestamped write families.
|
||||||
|
func parseRfc3339Timestamp(text string) (*mxgateway.MxValue, error) {
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid RFC 3339 timestamp %q: %w", text, err)
|
||||||
|
}
|
||||||
|
return mxgateway.TimestampValue(t), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBenchReadBulk drives the cross-language ReadBulk stress benchmark from Go:
|
||||||
|
// opens its own session, subscribes to bulk-size tags so the worker value cache
|
||||||
|
// populates from real OnDataChange events, runs ReadBulk in a tight loop for
|
||||||
|
// duration-seconds with per-call timing, and emits the shared JSON schema the
|
||||||
|
// scripts/bench-read-bulk.ps1 driver collates across all five clients.
|
||||||
|
func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("bench-read-bulk", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
clientName := flags.String("client-name", "mxgw-go-bench", "session client name")
|
||||||
|
durationSeconds := flags.Int("duration-seconds", 30, "steady-state measurement window in seconds")
|
||||||
|
warmupSeconds := flags.Int("warmup-seconds", 3, "warm-up window before measurement, in seconds")
|
||||||
|
bulkSize := flags.Int("bulk-size", 6, "tags per ReadBulk call")
|
||||||
|
tagStart := flags.Int("tag-start", 1, "first machine number")
|
||||||
|
tagPrefix := flags.String("tag-prefix", "TestMachine_", "tag prefix (machine number appended as %03d)")
|
||||||
|
tagAttribute := flags.String("tag-attribute", "TestChangingInt", "attribute appended to each tag prefix")
|
||||||
|
timeoutMs := flags.Int("timeout-ms", 1500, "per-tag snapshot timeout in milliseconds")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *bulkSize < 1 {
|
||||||
|
return errors.New("bulk-size must be positive")
|
||||||
|
}
|
||||||
|
if *durationSeconds < 1 {
|
||||||
|
return errors.New("duration-seconds must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make([]string, *bulkSize)
|
||||||
|
for i := 0; i < *bulkSize; i++ {
|
||||||
|
tags[i] = fmt.Sprintf("%s%03d.%s", *tagPrefix, *tagStart+i, *tagAttribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.OpenSession(ctx, mxgateway.OpenSessionOptions{ClientSessionName: *clientName})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = session.Close(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
serverHandle, err := session.Register(ctx, *clientName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeResults, err := session.SubscribeBulk(ctx, serverHandle, tags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
itemHandles := make([]int32, 0, len(subscribeResults))
|
||||||
|
for _, result := range subscribeResults {
|
||||||
|
if result.GetWasSuccessful() {
|
||||||
|
itemHandles = append(itemHandles, result.GetItemHandle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if len(itemHandles) > 0 {
|
||||||
|
_, _ = session.UnsubscribeBulk(context.Background(), serverHandle, itemHandles)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Warm-up: drive identical calls so any first-call JIT / connection-pool
|
||||||
|
// setup is amortised before the measurement window opens. The ctx.Err()
|
||||||
|
// guard short-circuits on Ctrl+C / parent-cancel instead of spinning
|
||||||
|
// failing ReadBulk calls until the wall-clock deadline elapses.
|
||||||
|
warmupDeadline := time.Now().Add(time.Duration(*warmupSeconds) * time.Second)
|
||||||
|
timeout := time.Duration(*timeoutMs) * time.Millisecond
|
||||||
|
for time.Now().Before(warmupDeadline) && ctx.Err() == nil {
|
||||||
|
_, _ = session.ReadBulk(ctx, serverHandle, tags, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady state: per-call latency captured via time.Now() deltas.
|
||||||
|
latenciesMs := make([]float64, 0, 65536)
|
||||||
|
var totalReadResults int64
|
||||||
|
var cachedReadResults int64
|
||||||
|
var successfulCalls, failedCalls int
|
||||||
|
steadyStart := time.Now()
|
||||||
|
steadyDeadline := steadyStart.Add(time.Duration(*durationSeconds) * time.Second)
|
||||||
|
|
||||||
|
for time.Now().Before(steadyDeadline) && ctx.Err() == nil {
|
||||||
|
callStart := time.Now()
|
||||||
|
results, err := session.ReadBulk(ctx, serverHandle, tags, timeout)
|
||||||
|
elapsed := time.Since(callStart)
|
||||||
|
latenciesMs = append(latenciesMs, float64(elapsed.Nanoseconds())/1e6)
|
||||||
|
if err != nil {
|
||||||
|
failedCalls++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
successfulCalls++
|
||||||
|
for _, r := range results {
|
||||||
|
totalReadResults++
|
||||||
|
if r.GetWasCached() {
|
||||||
|
cachedReadResults++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steadyElapsed := time.Since(steadyStart)
|
||||||
|
totalCalls := successfulCalls + failedCalls
|
||||||
|
|
||||||
|
callsPerSecond := 0.0
|
||||||
|
if steadyElapsed.Seconds() > 0 {
|
||||||
|
callsPerSecond = float64(totalCalls) / steadyElapsed.Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := map[string]any{
|
||||||
|
"language": "go",
|
||||||
|
"command": "bench-read-bulk",
|
||||||
|
"endpoint": options.Endpoint,
|
||||||
|
"clientName": *clientName,
|
||||||
|
"bulkSize": *bulkSize,
|
||||||
|
"durationSeconds": *durationSeconds,
|
||||||
|
"warmupSeconds": *warmupSeconds,
|
||||||
|
"durationMs": steadyElapsed.Milliseconds(),
|
||||||
|
"tags": tags,
|
||||||
|
"totalCalls": totalCalls,
|
||||||
|
"successfulCalls": successfulCalls,
|
||||||
|
"failedCalls": failedCalls,
|
||||||
|
"totalReadResults": totalReadResults,
|
||||||
|
"cachedReadResults": cachedReadResults,
|
||||||
|
"callsPerSecond": roundTo(callsPerSecond, 2),
|
||||||
|
"latencyMs": percentileSummary(latenciesMs),
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, stats)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, callsPerSecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// percentileSummary returns the same { p50, p95, p99, max, mean } shape every
|
||||||
|
// language bench emits, rounded to 3 decimal places so the PowerShell driver
|
||||||
|
// sees one schema across all five clients.
|
||||||
|
func percentileSummary(sample []float64) map[string]float64 {
|
||||||
|
if len(sample) == 0 {
|
||||||
|
return map[string]float64{"p50": 0, "p95": 0, "p99": 0, "max": 0, "mean": 0}
|
||||||
|
}
|
||||||
|
sorted := append([]float64(nil), sample...)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
mean := 0.0
|
||||||
|
maxValue := sorted[len(sorted)-1]
|
||||||
|
for _, v := range sample {
|
||||||
|
mean += v
|
||||||
|
}
|
||||||
|
mean /= float64(len(sample))
|
||||||
|
return map[string]float64{
|
||||||
|
"p50": roundTo(percentile(sorted, 0.50), 3),
|
||||||
|
"p95": roundTo(percentile(sorted, 0.95), 3),
|
||||||
|
"p99": roundTo(percentile(sorted, 0.99), 3),
|
||||||
|
"max": roundTo(maxValue, 3),
|
||||||
|
"mean": roundTo(mean, 3),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// percentile uses nearest-rank with linear interpolation; matches the .NET
|
||||||
|
// implementation so cross-language comparisons are apples-to-apples.
|
||||||
|
func percentile(sorted []float64, quantile float64) float64 {
|
||||||
|
if len(sorted) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(sorted) == 1 {
|
||||||
|
return sorted[0]
|
||||||
|
}
|
||||||
|
rank := quantile * float64(len(sorted)-1)
|
||||||
|
lower := int(rank)
|
||||||
|
upper := lower + 1
|
||||||
|
if upper >= len(sorted) {
|
||||||
|
return sorted[lower]
|
||||||
|
}
|
||||||
|
fraction := rank - float64(lower)
|
||||||
|
return sorted[lower] + (sorted[upper]-sorted[lower])*fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTo(value float64, digits int) float64 {
|
||||||
|
shift := 1.0
|
||||||
|
for i := 0; i < digits; i++ {
|
||||||
|
shift *= 10
|
||||||
|
}
|
||||||
|
return float64(int64(value*shift+0.5)) / shift
|
||||||
}
|
}
|
||||||
|
|
||||||
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
@@ -433,6 +815,119 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runStreamAlarms(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("stream-alarms", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
filterPrefix := flags.String("filter-prefix", "", "alarm-reference prefix scoping the feed; empty means unscoped")
|
||||||
|
limit := flags.Int("limit", 0, "maximum feed messages to read; 0 means unbounded")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, _, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Mirror runStreamEvents so Ctrl+C on a long-running stream-alarms command
|
||||||
|
// cancels the gRPC stream cleanly (the gateway sees codes.Canceled rather
|
||||||
|
// than a torn TCP connection) and the deferred client.Close() actually runs.
|
||||||
|
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignals()
|
||||||
|
|
||||||
|
streamCtx, cancelStream := context.WithCancel(signalCtx)
|
||||||
|
defer cancelStream()
|
||||||
|
stream, err := client.StreamAlarms(streamCtx, &mxgateway.StreamAlarmsRequest{AlarmFilterPrefix: *filterPrefix})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for {
|
||||||
|
message, err := stream.Recv()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
fmt.Fprintln(stdout, string(mustMarshalProto(message)))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(stdout, formatAlarmFeedMessage(message))
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if *limit > 0 && count >= *limit {
|
||||||
|
cancelStream()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAlarmFeedMessage renders one AlarmFeedMessage in the CLI's plain-text
|
||||||
|
// output style, distinguishing the active-alarm snapshot, snapshot-complete
|
||||||
|
// sentinel, and transition cases of the message's payload oneof.
|
||||||
|
func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||||
|
switch {
|
||||||
|
case message.GetActiveAlarm() != nil:
|
||||||
|
alarm := message.GetActiveAlarm()
|
||||||
|
return fmt.Sprintf("active-alarm %s state=%s severity=%d", alarm.GetAlarmFullReference(), alarm.GetCurrentState(), alarm.GetSeverity())
|
||||||
|
case message.GetSnapshotComplete():
|
||||||
|
return "snapshot-complete"
|
||||||
|
case message.GetTransition() != nil:
|
||||||
|
transition := message.GetTransition()
|
||||||
|
return fmt.Sprintf("transition %s kind=%s severity=%d", transition.GetAlarmFullReference(), transition.GetTransitionKind(), transition.GetSeverity())
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
reference := flags.String("reference", "", "full alarm reference to acknowledge")
|
||||||
|
comment := flags.String("comment", "", "operator acknowledge comment")
|
||||||
|
operator := flags.String("operator", "", "operator user performing the acknowledge")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *reference == "" {
|
||||||
|
return errors.New("reference is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
reply, err := client.AcknowledgeAlarm(ctx, &mxgateway.AcknowledgeAlarmRequest{
|
||||||
|
AlarmFullReference: *reference,
|
||||||
|
Comment: *comment,
|
||||||
|
OperatorUser: *operator,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, commandReplyOutput{
|
||||||
|
Command: "acknowledge-alarm",
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, reply.GetHresult())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
@@ -652,6 +1147,36 @@ func writeBulkOutput(stdout io.Writer, jsonOutput bool, command string, options
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeWriteBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.BulkWriteResult, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return writeJSON(stdout, map[string]any{
|
||||||
|
"command": command,
|
||||||
|
"options": options,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, len(results))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReadBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.BulkReadResult, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return writeJSON(stdout, map[string]any{
|
||||||
|
"command": command,
|
||||||
|
"options": options,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, len(results))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(writer io.Writer, value any) error {
|
func writeJSON(writer io.Writer, value any) error {
|
||||||
encoder := json.NewEncoder(writer)
|
encoder := json.NewEncoder(writer)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
@@ -671,7 +1196,67 @@ type protojsonMessage interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeUsage(writer io.Writer) {
|
func writeUsage(writer io.Writer) {
|
||||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch>")
|
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|batch>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchEOR is the end-of-result sentinel emitted to stdout after every command
|
||||||
|
// in batch mode, regardless of success or failure.
|
||||||
|
const batchEOR = "__MXGW_BATCH_EOR__"
|
||||||
|
|
||||||
|
// runBatch reads one command line at a time from in, dispatches each via the
|
||||||
|
// normal runWithIO routing, and writes a batchEOR sentinel to stdout after
|
||||||
|
// every result. Errors are serialised as JSON to stdout (not stderr) so the
|
||||||
|
// harness can parse them without interleaving stderr. Blank lines are
|
||||||
|
// skipped; only stdin EOF ends the session.
|
||||||
|
//
|
||||||
|
// The scanner buffer is widened to 16 MiB so a single long command line
|
||||||
|
// (e.g. a bulk-write with several thousand handles) does not trip the
|
||||||
|
// default 64 KiB bufio.Scanner token-too-long error and abort the session.
|
||||||
|
// If a line still exceeds the cap, the error is surfaced as a per-command
|
||||||
|
// error-with-sentinel and the session continues.
|
||||||
|
func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
bw := bufio.NewWriter(stdout)
|
||||||
|
scanner := bufio.NewScanner(in)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||||
|
for {
|
||||||
|
if !scanner.Scan() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line := scanner.Text()
|
||||||
|
args := strings.Fields(line)
|
||||||
|
if len(args) == 0 {
|
||||||
|
// Skip blank / whitespace-only lines; do NOT terminate. The
|
||||||
|
// session ends only on stdin EOF so a stray blank line in a
|
||||||
|
// PowerShell here-string does not silently drop later commands.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := runWithIO(ctx, args, bw, stderr); err != nil {
|
||||||
|
// Write error as JSON to stdout (bw) so the harness sees it in the
|
||||||
|
// same stream as normal output, framed by the EOR sentinel.
|
||||||
|
errPayload := map[string]string{
|
||||||
|
"error": err.Error(),
|
||||||
|
"type": "error",
|
||||||
|
}
|
||||||
|
_ = writeJSON(bw, errPayload)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(bw, batchEOR)
|
||||||
|
_ = bw.Flush()
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
// Emit the scanner failure as a final error-with-sentinel so the
|
||||||
|
// harness sees the failure framed, then return the error so the
|
||||||
|
// process exit reflects it. This handles bufio.ErrTooLong for any
|
||||||
|
// pathological line above the 16 MiB cap.
|
||||||
|
errPayload := map[string]string{
|
||||||
|
"error": err.Error(),
|
||||||
|
"type": "error",
|
||||||
|
}
|
||||||
|
_ = writeJSON(bw, errPayload)
|
||||||
|
_, _ = fmt.Fprintln(bw, batchEOR)
|
||||||
|
_ = bw.Flush()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
|
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunVersionJSON(t *testing.T) {
|
func TestRunVersionJSON(t *testing.T) {
|
||||||
@@ -47,6 +53,34 @@ func TestCommonOptionsRedactsAPIKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunBatchEmitsEORAfterVersion(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
in := strings.NewReader("version --json\n")
|
||||||
|
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
||||||
|
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "\n"+batchEOR+"\n") && !strings.HasSuffix(out, batchEOR+"\n") {
|
||||||
|
t.Fatalf("expected EOR marker %q in stdout; got: %q", batchEOR, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := strings.Index(out, batchEOR)
|
||||||
|
if idx <= 0 {
|
||||||
|
t.Fatalf("EOR marker not found or appeared before any output: %q", out)
|
||||||
|
}
|
||||||
|
payload := out[:idx]
|
||||||
|
var output versionOutput
|
||||||
|
if err := json.Unmarshal([]byte(payload), &output); err != nil {
|
||||||
|
t.Fatalf("parse JSON block before EOR: %v (payload=%q)", err, payload)
|
||||||
|
}
|
||||||
|
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
|
||||||
|
t.Fatalf("protocol versions were not populated: %+v", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseValueBuildsTypedValue(t *testing.T) {
|
func TestParseValueBuildsTypedValue(t *testing.T) {
|
||||||
value, err := parseValue("int32", "123")
|
value, err := parseValue("int32", "123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,31 +91,206 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInt32ListParsesValidTokens(t *testing.T) {
|
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
|
||||||
items, err := parseInt32List("1, 2 ,3")
|
// secured-only flags must be unavailable on non-secured variants, and
|
||||||
if err != nil {
|
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
|
||||||
t.Fatalf("parseInt32List() error = %v", err)
|
// but not defined" error instead of silently no-op'ing.
|
||||||
|
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "write-bulk-rejects-current-user-id",
|
||||||
|
args: []string{"write-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write-bulk-rejects-verifier-user-id",
|
||||||
|
args: []string{"write-bulk", "-verifier-user-id", "5", "-item-handles", "1", "-values", "1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write2-bulk-rejects-current-user-id",
|
||||||
|
args: []string{"write2-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write-secured-bulk-rejects-user-id",
|
||||||
|
args: []string{"write-secured-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write-secured2-bulk-rejects-user-id",
|
||||||
|
args: []string{"write-secured2-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
want := []int32{1, 2, 3}
|
for _, tc := range cases {
|
||||||
if len(items) != len(want) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Fatalf("parseInt32List() = %v, want %v", items, want)
|
var stdout, stderr bytes.Buffer
|
||||||
}
|
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
|
||||||
for i := range want {
|
if err == nil {
|
||||||
if items[i] != want[i] {
|
t.Fatalf("runWithIO(%v) returned no error", tc.args)
|
||||||
t.Fatalf("parseInt32List()[%d] = %d, want %d", i, items[i], want[i])
|
}
|
||||||
}
|
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
||||||
|
t.Fatalf("runWithIO(%v) error = %v; want 'flag provided but not defined'", tc.args, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInt32ListReturnsErrorOnMalformedToken(t *testing.T) {
|
// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
|
||||||
items, err := parseInt32List("1,foo")
|
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
|
||||||
if err == nil {
|
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
|
||||||
t.Fatalf("parseInt32List() error = nil, want a parse error; items = %v", items)
|
// driver) short-circuits the bench instead of spinning failing ReadBulk
|
||||||
|
// calls until the wall-clock deadline elapses.
|
||||||
|
func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
}
|
}
|
||||||
if items != nil {
|
server := grpc.NewServer()
|
||||||
t.Fatalf("parseInt32List() items = %v, want nil on error", items)
|
fake := &benchFakeGateway{}
|
||||||
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
||||||
|
go func() {
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
defer server.Stop()
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Long warm-up + duration, so if the ctx.Err() guard were missing the
|
||||||
|
// loops would run for ~10s. With the guard, the cancel below short-
|
||||||
|
// circuits both loops within ~one ReadBulk iteration.
|
||||||
|
args := []string{
|
||||||
|
"bench-read-bulk",
|
||||||
|
"-endpoint", listener.Addr().String(),
|
||||||
|
"-plaintext",
|
||||||
|
"-api-key", "test",
|
||||||
|
"-warmup-seconds", "5",
|
||||||
|
"-duration-seconds", "5",
|
||||||
|
"-bulk-size", "1",
|
||||||
|
"-timeout-ms", "100",
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "foo") {
|
|
||||||
t.Fatalf("parseInt32List() error = %q, want it to name the bad token", err.Error())
|
// Cancel after a brief delay — far less than warmup+duration (10s).
|
||||||
|
go func() {
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
start := time.Now()
|
||||||
|
err = runWithIO(ctx, args, &stdout, &stderr)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// With the ctx.Err() guard, the loops exit well before the wall-clock
|
||||||
|
// deadlines (warmup=5s + duration=5s = 10s). Allow generous slack for
|
||||||
|
// CI noise but assert clearly less than the un-guarded worst case.
|
||||||
|
if elapsed > 4*time.Second {
|
||||||
|
t.Fatalf("bench-read-bulk took %s after ctx cancel; want <4s (ctx.Err() guard missing?). err=%v stderr=%s", elapsed, err, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the
|
||||||
|
// bench-read-bulk session-setup sequence (OpenSession + Invoke for Register
|
||||||
|
// / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession).
|
||||||
|
type benchFakeGateway struct {
|
||||||
|
pb.UnimplementedMxAccessGatewayServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *benchFakeGateway) OpenSession(_ context.Context, _ *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
|
||||||
|
return &pb.OpenSessionReply{
|
||||||
|
SessionId: "bench-session",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *benchFakeGateway) CloseSession(_ context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
|
||||||
|
return &pb.CloseSessionReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *benchFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
||||||
|
kind := req.GetCommand().GetKind()
|
||||||
|
reply := &pb.MxCommandReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
Kind: kind,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case pb.MxCommandKind_MX_COMMAND_KIND_REGISTER:
|
||||||
|
reply.Payload = &pb.MxCommandReply_Register{Register: &pb.RegisterReply{ServerHandle: 1}}
|
||||||
|
case pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK:
|
||||||
|
reply.Payload = &pb.MxCommandReply_SubscribeBulk{SubscribeBulk: &pb.BulkSubscribeReply{
|
||||||
|
Results: []*pb.SubscribeResult{{ServerHandle: 1, ItemHandle: 1, WasSuccessful: true}},
|
||||||
|
}}
|
||||||
|
case pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK:
|
||||||
|
reply.Payload = &pb.MxCommandReply_ReadBulk{ReadBulk: &pb.BulkReadReply{
|
||||||
|
Results: []*pb.BulkReadResult{{ItemHandle: 1, WasSuccessful: true, WasCached: true}},
|
||||||
|
}}
|
||||||
|
case pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
|
||||||
|
reply.Payload = &pb.MxCommandReply_UnsubscribeBulk{UnsubscribeBulk: &pb.BulkSubscribeReply{}}
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the Client.Go-023-adjacent
|
||||||
|
// positivity checks so they cannot drift while resolving the cancellation finding.
|
||||||
|
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), []string{"bench-read-bulk", "-bulk-size", "0"}, &stdout, &stderr)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "bulk-size must be positive") {
|
||||||
|
t.Fatalf("bench-read-bulk -bulk-size 0 error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBatchSkipsBlankLinesAndContinuesUntilEOF pins the Client.Go-027 fix:
|
||||||
|
// a blank line in the middle of a batch session must NOT terminate the loop —
|
||||||
|
// only stdin EOF ends the session.
|
||||||
|
func TestRunBatchSkipsBlankLinesAndContinuesUntilEOF(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
// version -> blank -> version (a stray blank line in the middle of a
|
||||||
|
// programmatic session).
|
||||||
|
in := strings.NewReader("version --json\n\nversion --json\n")
|
||||||
|
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
||||||
|
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// Both version commands must have produced a result before the EOR sentinel.
|
||||||
|
if count := strings.Count(out, batchEOR); count != 2 {
|
||||||
|
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, blank line skipped); out = %q", count, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBatchHandlesLongCommandLine pins the Client.Go-026 fix: a command
|
||||||
|
// line longer than the default bufio.Scanner token size (64 KiB) must not
|
||||||
|
// abort the batch session.
|
||||||
|
func TestRunBatchHandlesLongCommandLine(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
// Build a single command line larger than 64 KiB. The command itself is
|
||||||
|
// invalid (no real session) but runBatch must still emit an EOR sentinel
|
||||||
|
// and continue to the next command rather than dropping the line on the
|
||||||
|
// floor with a bufio.ErrTooLong from the outer return.
|
||||||
|
huge := strings.Repeat("tag-with-a-reasonably-long-name-and-suffix,", 2000) + "trailing"
|
||||||
|
line := "subscribe-bulk -session-id none -items " + huge
|
||||||
|
if len(line) <= 64*1024 {
|
||||||
|
t.Fatalf("test setup error: long line length = %d, want > 64KiB", len(line))
|
||||||
|
}
|
||||||
|
in := strings.NewReader(line + "\nversion --json\n")
|
||||||
|
|
||||||
|
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
|
||||||
|
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// Both commands must produce an EOR sentinel — the long line should be a
|
||||||
|
// per-command error (still emitted with EOR), then the version command
|
||||||
|
// should run normally.
|
||||||
|
if count := strings.Count(out, batchEOR); count != 2 {
|
||||||
|
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Set-StrictMode -Version Latest
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||||
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
|
$protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
|
||||||
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||||
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||||
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||||
|
|||||||
@@ -687,18 +687,32 @@ func (x *GalaxyObject) GetAttributes() []*GalaxyAttribute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GalaxyAttribute struct {
|
type GalaxyAttribute struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
|
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
|
||||||
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
|
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
|
||||||
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
|
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||||
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
|
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||||
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
|
// type enumeration is distinct from MXAccess's wire data-type enum and
|
||||||
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
|
// the two must not be cast or compared. The GalaxyRepository service is
|
||||||
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
|
// metadata-only and deliberately does not share types with
|
||||||
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
|
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||||
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
|
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
|
||||||
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
|
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||||
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
|
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||||
|
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
|
||||||
|
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
|
||||||
|
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
|
||||||
|
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
|
||||||
|
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||||
|
// Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
|
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
|
||||||
|
// Raw Galaxy SQL security-classification identifier, passed through
|
||||||
|
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||||
|
// docs/GalaxyRepository.md.
|
||||||
|
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
|
||||||
|
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
|
||||||
|
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -810,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 = "" +
|
||||||
@@ -883,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 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
|
||||||
@@ -902,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
|
||||||
@@ -914,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() }
|
||||||
@@ -950,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{
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||||
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||||
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
|
||||||
|
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
|
||||||
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
|
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +39,20 @@ type MxAccessGatewayClient interface {
|
|||||||
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
||||||
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
||||||
AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error)
|
AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error)
|
||||||
|
// Session-less central alarm feed. The stream opens with the current
|
||||||
|
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
// fan out from the single monitor without opening a worker session.
|
||||||
|
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
|
||||||
|
// Point-in-time snapshot of the currently-active alarm set served from the
|
||||||
|
// gateway's always-on alarm monitor cache (session-less). Used after a
|
||||||
|
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
|
// have been missed during a transport blip. Streamed so callers can
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +123,28 @@ func (c *mxAccessGatewayClient) AcknowledgeAlarm(ctx context.Context, in *Acknow
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_StreamAlarms_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[StreamAlarmsRequest, AlarmFeedMessage]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
|
||||||
|
|
||||||
func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) {
|
func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_QueryActiveAlarms_FullMethodName, cOpts...)
|
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[2], MxAccessGateway_QueryActiveAlarms_FullMethodName, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -138,6 +172,20 @@ type MxAccessGatewayServer interface {
|
|||||||
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
||||||
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
||||||
AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error)
|
AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error)
|
||||||
|
// Session-less central alarm feed. The stream opens with the current
|
||||||
|
// active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
// `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
// Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
// fan out from the single monitor without opening a worker session.
|
||||||
|
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
|
||||||
|
// Point-in-time snapshot of the currently-active alarm set served from the
|
||||||
|
// gateway's always-on alarm monitor cache (session-less). Used after a
|
||||||
|
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
|
// have been missed during a transport blip. Streamed so callers can
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
@@ -164,6 +212,9 @@ func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grp
|
|||||||
func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method AcknowledgeAlarm not implemented")
|
return nil, status.Error(codes.Unimplemented, "method AcknowledgeAlarm not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedMxAccessGatewayServer) QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error {
|
func (UnimplementedMxAccessGatewayServer) QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error {
|
||||||
return status.Error(codes.Unimplemented, "method QueryActiveAlarms not implemented")
|
return status.Error(codes.Unimplemented, "method QueryActiveAlarms not implemented")
|
||||||
}
|
}
|
||||||
@@ -271,6 +322,17 @@ func _MxAccessGateway_AcknowledgeAlarm_Handler(srv interface{}, ctx context.Cont
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_StreamAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(StreamAlarmsRequest)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(MxAccessGatewayServer).StreamAlarms(m, &grpc.GenericServerStream[StreamAlarmsRequest, AlarmFeedMessage]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
|
||||||
|
|
||||||
func _MxAccessGateway_QueryActiveAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
func _MxAccessGateway_QueryActiveAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
m := new(QueryActiveAlarmsRequest)
|
m := new(QueryActiveAlarmsRequest)
|
||||||
if err := stream.RecvMsg(m); err != nil {
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
@@ -312,6 +374,11 @@ var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Handler: _MxAccessGateway_StreamEvents_Handler,
|
Handler: _MxAccessGateway_StreamEvents_Handler,
|
||||||
ServerStreams: true,
|
ServerStreams: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
StreamName: "StreamAlarms",
|
||||||
|
Handler: _MxAccessGateway_StreamAlarms_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
StreamName: "QueryActiveAlarms",
|
StreamName: "QueryActiveAlarms",
|
||||||
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
|
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
|
||||||
|
|||||||
@@ -1179,7 +1179,7 @@ const file_mxaccess_worker_proto_rawDesc = "" +
|
|||||||
"\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n" +
|
"\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n" +
|
||||||
"$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n" +
|
"$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n" +
|
||||||
"\x12*\n" +
|
"\x12*\n" +
|
||||||
"&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB\x1c\xaa\x02\x19MxGateway.Contracts.Protob\x06proto3"
|
"&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\vB&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_mxaccess_worker_proto_rawDescOnce sync.Once
|
file_mxaccess_worker_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -51,3 +51,26 @@ func (c *Client) QueryActiveAlarms(ctx context.Context, req *QueryActiveAlarmsRe
|
|||||||
|
|
||||||
return stream, nil
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
|
||||||
|
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
|
||||||
|
// snapshot), then a single snapshot-complete sentinel, then a transition for
|
||||||
|
// every subsequent raise / acknowledge / clear. It is served by the gateway's
|
||||||
|
// always-on alarm monitor — no worker session is opened — so any number of
|
||||||
|
// clients may attach.
|
||||||
|
//
|
||||||
|
// The returned stream is owned by the caller; cancel ctx to release it.
|
||||||
|
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
|
||||||
|
// stream to a sub-tree.
|
||||||
|
func (c *Client) StreamAlarms(ctx context.Context, req *StreamAlarmsRequest) (StreamAlarmsClient, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: stream alarms request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.raw.StreamAlarms(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "stream alarms", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||||
fake := &fakeGatewayWithAlarms{
|
fake := &fakeGatewayWithAlarms{
|
||||||
acknowledgeReply: &pb.AcknowledgeAlarmReply{
|
acknowledgeReply: &pb.AcknowledgeAlarmReply{
|
||||||
SessionId: "session-1",
|
|
||||||
CorrelationId: "corr-1",
|
CorrelationId: "corr-1",
|
||||||
ProtocolStatus: &pb.ProtocolStatus{
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
@@ -35,7 +34,6 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
reply, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
reply, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||||
SessionId: "session-1",
|
|
||||||
ClientCorrelationId: "corr-1",
|
ClientCorrelationId: "corr-1",
|
||||||
AlarmFullReference: "Tank01.Level.HiHi",
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
Comment: "investigating",
|
Comment: "investigating",
|
||||||
@@ -81,7 +79,6 @@ func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||||
SessionId: "session-1",
|
|
||||||
AlarmFullReference: "Tank01.Level.HiHi",
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
OperatorUser: "alice",
|
OperatorUser: "alice",
|
||||||
})
|
})
|
||||||
@@ -150,8 +147,8 @@ func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
||||||
SessionId: "session-1",
|
SessionId: "session-1",
|
||||||
AlarmFilterPrefix: "Tank01.",
|
AlarmFilterPrefix: "Tank01.",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QueryActiveAlarms() error = %v", err)
|
t.Fatalf("QueryActiveAlarms() error = %v", err)
|
||||||
@@ -171,6 +168,66 @@ func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStreamAlarmsPassesFilterPrefixAndReceivesFeedMessages(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{
|
||||||
|
feedMessages: []*pb.AlarmFeedMessage{
|
||||||
|
{
|
||||||
|
Payload: &pb.AlarmFeedMessage_ActiveAlarm{
|
||||||
|
ActiveAlarm: &pb.ActiveAlarmSnapshot{
|
||||||
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
|
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Payload: &pb.AlarmFeedMessage_SnapshotComplete{
|
||||||
|
SnapshotComplete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
|
||||||
|
AlarmFilterPrefix: "Tank01.",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamAlarms() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var received []*pb.AlarmFeedMessage
|
||||||
|
for {
|
||||||
|
msg, err := stream.Recv()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stream.Recv() error = %v", err)
|
||||||
|
}
|
||||||
|
received = append(received, msg)
|
||||||
|
}
|
||||||
|
if len(received) != 2 {
|
||||||
|
t.Fatalf("received count = %d, want 2", len(received))
|
||||||
|
}
|
||||||
|
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
|
||||||
|
t.Fatalf("captured filter prefix = %q", got)
|
||||||
|
}
|
||||||
|
if got := fake.streamAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("stream authorization metadata = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamAlarmsRejectsNilRequest(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := client.StreamAlarms(context.Background(), nil); err == nil {
|
||||||
|
t.Fatal("StreamAlarms(nil) returned no error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeGatewayWithAlarms struct {
|
type fakeGatewayWithAlarms struct {
|
||||||
pb.UnimplementedMxAccessGatewayServer
|
pb.UnimplementedMxAccessGatewayServer
|
||||||
|
|
||||||
@@ -181,6 +238,10 @@ type fakeGatewayWithAlarms struct {
|
|||||||
|
|
||||||
queryRequest *pb.QueryActiveAlarmsRequest
|
queryRequest *pb.QueryActiveAlarmsRequest
|
||||||
activeSnapshots []*pb.ActiveAlarmSnapshot
|
activeSnapshots []*pb.ActiveAlarmSnapshot
|
||||||
|
|
||||||
|
streamRequest *pb.StreamAlarmsRequest
|
||||||
|
feedMessages []*pb.AlarmFeedMessage
|
||||||
|
streamAuth string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
|
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
|
||||||
@@ -193,7 +254,7 @@ func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.Ac
|
|||||||
return s.acknowledgeReply, nil
|
return s.acknowledgeReply, nil
|
||||||
}
|
}
|
||||||
return &pb.AcknowledgeAlarmReply{
|
return &pb.AcknowledgeAlarmReply{
|
||||||
SessionId: req.GetSessionId(),
|
CorrelationId: req.GetClientCorrelationId(),
|
||||||
ProtocolStatus: &pb.ProtocolStatus{
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
},
|
},
|
||||||
@@ -210,6 +271,17 @@ func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsReque
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
|
||||||
|
s.streamRequest = req
|
||||||
|
s.streamAuth = authorizationFromContext(stream.Context())
|
||||||
|
for _, msg := range s.feedMessages {
|
||||||
|
if err := stream.Send(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
listener := bufconn.Listen(bufSize)
|
listener := bufconn.Listen(bufSize)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
|
|||||||
fake := &fakeGatewayServer{
|
fake := &fakeGatewayServer{
|
||||||
streamStarted: make(chan struct{}),
|
streamStarted: make(chan struct{}),
|
||||||
streamDone: make(chan struct{}),
|
streamDone: make(chan struct{}),
|
||||||
streamEventCount: 256,
|
streamEventCount: 64,
|
||||||
}
|
}
|
||||||
client, cleanup := newBufconnClient(t, fake)
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -135,25 +135,12 @@ func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.
|
|||||||
t.Fatal("compatibility event stream did not stop after result channel filled")
|
t.Fatal("compatibility event stream did not stop after result channel filled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A slow consumer that abandons the buffer must still receive an explicit
|
|
||||||
// terminal overflow error before the channel closes, so it can tell
|
|
||||||
// "events dropped" apart from "stream ended normally".
|
|
||||||
var sawOverflow bool
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case result, ok := <-events:
|
case _, ok := <-events:
|
||||||
if !ok {
|
if !ok {
|
||||||
if !sawOverflow {
|
|
||||||
t.Fatal("compatibility event channel closed without an ErrEventBufferOverflow result")
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if result.Err != nil {
|
|
||||||
if !errors.Is(result.Err, ErrEventBufferOverflow) {
|
|
||||||
t.Fatalf("terminal result error = %v, want ErrEventBufferOverflow", result.Err)
|
|
||||||
}
|
|
||||||
sawOverflow = true
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("compatibility event channel did not close")
|
t.Fatal("compatibility event channel did not close")
|
||||||
}
|
}
|
||||||
@@ -243,6 +230,206 @@ func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_WriteBulk{
|
||||||
|
WriteBulk: &pb.BulkWriteReply{
|
||||||
|
Results: []*pb.BulkWriteResult{
|
||||||
|
{ItemHandle: 10, WasSuccessful: true},
|
||||||
|
{ItemHandle: 11, WasSuccessful: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
entries := []*WriteBulkEntry{
|
||||||
|
{ItemHandle: 10, Value: Int32Value(7), UserId: 100},
|
||||||
|
{ItemHandle: 11, Value: Int32Value(8), UserId: 100},
|
||||||
|
}
|
||||||
|
results, err := session.WriteBulk(context.Background(), 12, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteBulk() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("results len = %d, want 2", len(results))
|
||||||
|
}
|
||||||
|
req := fake.invokeRequest
|
||||||
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
|
||||||
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
|
}
|
||||||
|
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
|
||||||
|
t.Fatalf("entry count = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteBulkRejectsNilEntries(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
if _, err := session.WriteBulk(context.Background(), 12, nil); err == nil {
|
||||||
|
t.Fatal("WriteBulk(nil) returned no error")
|
||||||
|
}
|
||||||
|
if _, err := session.Write2Bulk(context.Background(), 12, nil); err == nil {
|
||||||
|
t.Fatal("Write2Bulk(nil) returned no error")
|
||||||
|
}
|
||||||
|
if _, err := session.WriteSecuredBulk(context.Background(), 12, nil); err == nil {
|
||||||
|
t.Fatal("WriteSecuredBulk(nil) returned no error")
|
||||||
|
}
|
||||||
|
if _, err := session.WriteSecured2Bulk(context.Background(), 12, nil); err == nil {
|
||||||
|
t.Fatal("WriteSecured2Bulk(nil) returned no error")
|
||||||
|
}
|
||||||
|
if _, err := session.ReadBulk(context.Background(), 12, nil, 0); err == nil {
|
||||||
|
t.Fatal("ReadBulk(nil) returned no error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkMethodsShortCircuitOnEmptySliceWithoutRoundTrip(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
results, err := session.WriteBulk(context.Background(), 12, []*WriteBulkEntry{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteBulk(empty) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Fatalf("WriteBulk(empty) results len = %d, want 0", len(results))
|
||||||
|
}
|
||||||
|
if fake.invokeRequest != nil {
|
||||||
|
t.Fatal("WriteBulk(empty) sent a round trip; expected short-circuit")
|
||||||
|
}
|
||||||
|
|
||||||
|
results2, err := session.Write2Bulk(context.Background(), 12, []*Write2BulkEntry{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write2Bulk(empty) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results2) != 0 {
|
||||||
|
t.Fatalf("Write2Bulk(empty) results len = %d, want 0", len(results2))
|
||||||
|
}
|
||||||
|
if fake.invokeRequest != nil {
|
||||||
|
t.Fatal("Write2Bulk(empty) sent a round trip; expected short-circuit")
|
||||||
|
}
|
||||||
|
|
||||||
|
results3, err := session.WriteSecuredBulk(context.Background(), 12, []*WriteSecuredBulkEntry{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteSecuredBulk(empty) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results3) != 0 {
|
||||||
|
t.Fatalf("WriteSecuredBulk(empty) results len = %d, want 0", len(results3))
|
||||||
|
}
|
||||||
|
if fake.invokeRequest != nil {
|
||||||
|
t.Fatal("WriteSecuredBulk(empty) sent a round trip; expected short-circuit")
|
||||||
|
}
|
||||||
|
|
||||||
|
results4, err := session.WriteSecured2Bulk(context.Background(), 12, []*WriteSecured2BulkEntry{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteSecured2Bulk(empty) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results4) != 0 {
|
||||||
|
t.Fatalf("WriteSecured2Bulk(empty) results len = %d, want 0", len(results4))
|
||||||
|
}
|
||||||
|
if fake.invokeRequest != nil {
|
||||||
|
t.Fatal("WriteSecured2Bulk(empty) sent a round trip; expected short-circuit")
|
||||||
|
}
|
||||||
|
|
||||||
|
readResults, err := session.ReadBulk(context.Background(), 12, []string{}, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadBulk(empty) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(readResults) != 0 {
|
||||||
|
t.Fatalf("ReadBulk(empty) results len = %d, want 0", len(readResults))
|
||||||
|
}
|
||||||
|
if fake.invokeRequest != nil {
|
||||||
|
t.Fatal("ReadBulk(empty) sent a round trip; expected short-circuit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_ReadBulk{
|
||||||
|
ReadBulk: &pb.BulkReadReply{
|
||||||
|
Results: []*pb.BulkReadResult{
|
||||||
|
{TagAddress: "Tank01.Level", WasSuccessful: true, WasCached: true},
|
||||||
|
{TagAddress: "Tank02.Level", WasSuccessful: true, WasCached: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
results, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level", "Tank02.Level"}, 250*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadBulk() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("results len = %d, want 2", len(results))
|
||||||
|
}
|
||||||
|
if !results[0].GetWasCached() || results[1].GetWasCached() {
|
||||||
|
t.Fatalf("WasCached flags = [%v %v], want [true false]", results[0].GetWasCached(), results[1].GetWasCached())
|
||||||
|
}
|
||||||
|
req := fake.invokeRequest
|
||||||
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK {
|
||||||
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
|
}
|
||||||
|
if got := req.GetCommand().GetReadBulk().GetTimeoutMs(); got != 250 {
|
||||||
|
t.Fatalf("timeout ms = %d, want 250", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadBulkSaturatesTimeoutAboveMaxUint32(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
// 100 days in milliseconds exceeds MaxUint32 (~49.7 days).
|
||||||
|
hugeTimeout := 100 * 24 * time.Hour
|
||||||
|
_, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level"}, hugeTimeout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadBulk() error = %v", err)
|
||||||
|
}
|
||||||
|
got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs()
|
||||||
|
if got != ^uint32(0) {
|
||||||
|
t.Fatalf("timeout ms = %d, want %d (MaxUint32)", got, ^uint32(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
|
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
|
||||||
hresult := int32(-2147467259)
|
hresult := int32(-2147467259)
|
||||||
fake := &fakeGatewayServer{
|
fake := &fakeGatewayServer{
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,11 @@
|
|||||||
package mxgateway
|
package mxgateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrEventBufferOverflow is the terminal error delivered on the compatibility
|
|
||||||
// event channel returned by Session.Events / Session.EventsAfter when a slow
|
|
||||||
// consumer lets the bounded result buffer fill. It signals that the stream was
|
|
||||||
// cancelled and events were dropped, so a consumer can tell an overflow apart
|
|
||||||
// from a normal end-of-stream. Use Session.SubscribeEvents to block instead of
|
|
||||||
// dropping.
|
|
||||||
var ErrEventBufferOverflow = errors.New("mxgateway: event buffer overflow; compatibility stream cancelled and events dropped")
|
|
||||||
|
|
||||||
// GatewayError wraps transport-level gRPC failures.
|
// GatewayError wraps transport-level gRPC failures.
|
||||||
type GatewayError struct {
|
type GatewayError struct {
|
||||||
// Op names the operation that failed (for example "dial" or "invoke").
|
// Op names the operation that failed (for example "dial" or "invoke").
|
||||||
@@ -94,12 +85,8 @@ func (e *MxAccessError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap returns the wrapped CommandError, when one is present.
|
// Unwrap returns the wrapped CommandError, when one is present.
|
||||||
//
|
|
||||||
// When Command is nil (the HRESULT / MxStatusProxy path) it returns an
|
|
||||||
// untyped nil rather than a typed-nil *CommandError, so errors.As does not
|
|
||||||
// bind a nil pointer that a caller would then panic on.
|
|
||||||
func (e *MxAccessError) Unwrap() error {
|
func (e *MxAccessError) Unwrap() error {
|
||||||
if e == nil || e.Command == nil {
|
if e == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return e.Command
|
return e.Command
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
package mxgateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError reproduces
|
|
||||||
// Client.Go-001: an MxAccessError built via the HRESULT / MxStatusProxy path
|
|
||||||
// leaves Command nil. Unwrap must not hand back a typed-nil *CommandError,
|
|
||||||
// because errors.As would then succeed while binding a nil pointer and a
|
|
||||||
// caller dereferencing it would panic.
|
|
||||||
func TestMxAccessErrorUnwrapHResultPathNoTypedNilCommandError(t *testing.T) {
|
|
||||||
hresult := int32(-2147467259) // 0x80004005, a failing HRESULT.
|
|
||||||
reply := &MxCommandReply{Hresult: &hresult}
|
|
||||||
|
|
||||||
err := EnsureMxAccessSuccess("invoke", reply)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected MxAccessError for a failing HRESULT, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
var ce *CommandError
|
|
||||||
if errors.As(err, &ce) {
|
|
||||||
t.Fatalf("errors.As bound *CommandError from an HRESULT-only MxAccessError (ce=%v); "+
|
|
||||||
"a caller dereferencing ce.Status would panic", ce)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMxAccessErrorUnwrapPopulatedCommand confirms the non-nil Command path
|
|
||||||
// still unwraps to the wrapped *CommandError.
|
|
||||||
func TestMxAccessErrorUnwrapPopulatedCommand(t *testing.T) {
|
|
||||||
command := &CommandError{Op: "invoke"}
|
|
||||||
err := &MxAccessError{Command: command}
|
|
||||||
|
|
||||||
var ce *CommandError
|
|
||||||
if !errors.As(err, &ce) {
|
|
||||||
t.Fatal("errors.As failed to bind the populated *CommandError")
|
|
||||||
}
|
|
||||||
if ce != command {
|
|
||||||
t.Fatalf("errors.As bound an unexpected *CommandError: got %v want %v", ce, command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -55,8 +58,8 @@ func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
|
|||||||
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
|
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
|
||||||
fake := &fakeGalaxyServer{
|
fake := &fakeGalaxyServer{
|
||||||
deployReply: &pb.GetLastDeployTimeReply{
|
deployReply: &pb.GetLastDeployTimeReply{
|
||||||
Present: true,
|
Present: true,
|
||||||
TimeOfLastDeploy: timestamppb.New(want),
|
TimeOfLastDeploy: timestamppb.New(want),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
@@ -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
|
||||||
|
|||||||
+169
-25
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"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/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -387,6 +388,173 @@ func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemH
|
|||||||
return reply.GetUnsubscribeBulk().GetResults(), nil
|
return reply.GetUnsubscribeBulk().GetResults(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteBulk invokes MXAccess Write sequentially for each entry inside one gateway command.
|
||||||
|
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
|
||||||
|
// never returns an error for per-entry MXAccess failures (it returns an error only for
|
||||||
|
// protocol-level failures or transport errors).
|
||||||
|
//
|
||||||
|
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
||||||
|
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
||||||
|
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return []*BulkWriteResult{}, nil
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||||
|
Payload: &pb.MxCommand_WriteBulk{
|
||||||
|
WriteBulk: &pb.WriteBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWriteBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
|
||||||
|
//
|
||||||
|
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
||||||
|
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
||||||
|
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write2 bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return []*BulkWriteResult{}, nil
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
|
||||||
|
Payload: &pb.MxCommand_Write2Bulk{
|
||||||
|
Write2Bulk: &pb.Write2BulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWrite2Bulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
|
||||||
|
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
|
||||||
|
//
|
||||||
|
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
||||||
|
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
||||||
|
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write-secured bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return []*BulkWriteResult{}, nil
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||||
|
Payload: &pb.MxCommand_WriteSecuredBulk{
|
||||||
|
WriteSecuredBulk: &pb.WriteSecuredBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWriteSecuredBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
|
||||||
|
//
|
||||||
|
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
|
||||||
|
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
|
||||||
|
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
|
||||||
|
if entries == nil {
|
||||||
|
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return []*BulkWriteResult{}, nil
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||||
|
Payload: &pb.MxCommand_WriteSecured2Bulk{
|
||||||
|
WriteSecured2Bulk: &pb.WriteSecured2BulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
Entries: entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetWriteSecured2Bulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBulk snapshots the current value of each requested tag.
|
||||||
|
//
|
||||||
|
// MXAccess COM has no synchronous Read; the worker satisfies this by returning the
|
||||||
|
// most recent cached OnDataChange value when the tag is already advised (WasCached=true),
|
||||||
|
// or by taking a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||||
|
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
|
||||||
|
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
|
||||||
|
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
|
||||||
|
//
|
||||||
|
// A non-nil but empty tagAddresses slice is treated as a no-op and returns an empty
|
||||||
|
// result without a wire round-trip; pass nil to surface a clear "tag addresses are
|
||||||
|
// required" error.
|
||||||
|
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
|
||||||
|
if tagAddresses == nil {
|
||||||
|
return nil, errors.New("mxgateway: tag addresses are required")
|
||||||
|
}
|
||||||
|
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tagAddresses) == 0 {
|
||||||
|
return []*BulkReadResult{}, nil
|
||||||
|
}
|
||||||
|
var timeoutMs uint32
|
||||||
|
if timeout > 0 {
|
||||||
|
ms := timeout.Milliseconds()
|
||||||
|
if ms > int64(^uint32(0)) {
|
||||||
|
timeoutMs = ^uint32(0)
|
||||||
|
} else {
|
||||||
|
timeoutMs = uint32(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||||
|
Payload: &pb.MxCommand_ReadBulk{
|
||||||
|
ReadBulk: &pb.ReadBulkCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
TagAddresses: tagAddresses,
|
||||||
|
TimeoutMs: timeoutMs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply.GetReadBulk().GetResults(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Write invokes MXAccess Write.
|
// Write invokes MXAccess Write.
|
||||||
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
||||||
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
||||||
@@ -490,7 +658,7 @@ func ensureBulkSize(name string, length int) error {
|
|||||||
|
|
||||||
func sendEventResult(
|
func sendEventResult(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
results chan EventResult,
|
results chan<- EventResult,
|
||||||
result EventResult,
|
result EventResult,
|
||||||
cancelWhenBufferFull bool,
|
cancelWhenBufferFull bool,
|
||||||
cancel context.CancelFunc,
|
cancel context.CancelFunc,
|
||||||
@@ -502,12 +670,7 @@ func sendEventResult(
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
// The bounded compatibility buffer is full. Cancel the stream and
|
|
||||||
// deliver an explicit terminal overflow error so a slow consumer
|
|
||||||
// can tell dropped events apart from a normal end-of-stream,
|
|
||||||
// rather than seeing the channel close silently.
|
|
||||||
cancel()
|
cancel()
|
||||||
deliverTerminalResult(results, EventResult{Err: ErrEventBufferOverflow})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,25 +683,6 @@ func sendEventResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverTerminalResult places result on a full buffered channel by evicting
|
|
||||||
// one of the oldest buffered events to make room. The caller closes results
|
|
||||||
// afterwards, so the terminal result becomes the consumer's last item.
|
|
||||||
func deliverTerminalResult(results chan EventResult, result EventResult) {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case results <- result:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-results:
|
|
||||||
default:
|
|
||||||
// Another receiver drained the channel between the send and
|
|
||||||
// receive attempts; retry the send.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
|
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
|
||||||
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
||||||
SessionId: s.ID(),
|
SessionId: s.ID(),
|
||||||
|
|||||||
@@ -70,6 +70,32 @@ type (
|
|||||||
WriteCommand = pb.WriteCommand
|
WriteCommand = pb.WriteCommand
|
||||||
// Write2Command is the payload of an MXAccess Write2 command.
|
// Write2Command is the payload of an MXAccess Write2 command.
|
||||||
Write2Command = pb.Write2Command
|
Write2Command = pb.Write2Command
|
||||||
|
// WriteBulkCommand is the payload of a bulk Write command.
|
||||||
|
WriteBulkCommand = pb.WriteBulkCommand
|
||||||
|
// WriteBulkEntry is one entry inside a WriteBulkCommand.
|
||||||
|
WriteBulkEntry = pb.WriteBulkEntry
|
||||||
|
// Write2BulkCommand is the payload of a bulk Write2 (timestamped) command.
|
||||||
|
Write2BulkCommand = pb.Write2BulkCommand
|
||||||
|
// Write2BulkEntry is one entry inside a Write2BulkCommand.
|
||||||
|
Write2BulkEntry = pb.Write2BulkEntry
|
||||||
|
// WriteSecuredBulkCommand is the payload of a bulk WriteSecured command.
|
||||||
|
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
|
||||||
|
// WriteSecuredBulkEntry is one entry inside a WriteSecuredBulkCommand.
|
||||||
|
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
|
||||||
|
// WriteSecured2BulkCommand is the payload of a bulk WriteSecured2 (timestamped) command.
|
||||||
|
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
|
||||||
|
// WriteSecured2BulkEntry is one entry inside a WriteSecured2BulkCommand.
|
||||||
|
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
|
||||||
|
// ReadBulkCommand is the payload of a bulk Read snapshot command.
|
||||||
|
ReadBulkCommand = pb.ReadBulkCommand
|
||||||
|
// BulkWriteReply aggregates BulkWriteResult entries for a bulk write command.
|
||||||
|
BulkWriteReply = pb.BulkWriteReply
|
||||||
|
// BulkWriteResult is one entry in a bulk write reply list.
|
||||||
|
BulkWriteResult = pb.BulkWriteResult
|
||||||
|
// BulkReadReply aggregates BulkReadResult entries for a bulk read command.
|
||||||
|
BulkReadReply = pb.BulkReadReply
|
||||||
|
// BulkReadResult is one entry in a bulk read reply list.
|
||||||
|
BulkReadResult = pb.BulkReadResult
|
||||||
// RegisterReply carries the ServerHandle returned by Register.
|
// RegisterReply carries the ServerHandle returned by Register.
|
||||||
RegisterReply = pb.RegisterReply
|
RegisterReply = pb.RegisterReply
|
||||||
// AddItemReply carries the ItemHandle returned by AddItem.
|
// AddItemReply carries the ItemHandle returned by AddItem.
|
||||||
@@ -86,6 +112,11 @@ type (
|
|||||||
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
||||||
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
|
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
|
||||||
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
|
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
|
||||||
|
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
||||||
|
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
||||||
|
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
||||||
|
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
||||||
|
AlarmFeedMessage = pb.AlarmFeedMessage
|
||||||
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
|
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
|
||||||
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
||||||
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||||
@@ -104,6 +135,10 @@ type AlarmConditionState = pb.AlarmConditionState
|
|||||||
// QueryActiveAlarms RPC.
|
// QueryActiveAlarms RPC.
|
||||||
type QueryActiveAlarmsClient = pb.MxAccessGateway_QueryActiveAlarmsClient
|
type QueryActiveAlarmsClient = pb.MxAccessGateway_QueryActiveAlarmsClient
|
||||||
|
|
||||||
|
// StreamAlarmsClient is the generated server-streaming client for the
|
||||||
|
// StreamAlarms RPC.
|
||||||
|
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
|
||||||
|
|
||||||
// Enumerations from the generated contract re-exported for client callers.
|
// Enumerations from the generated contract re-exported for client callers.
|
||||||
type (
|
type (
|
||||||
// MxCommandKind discriminates which MXAccess command an MxCommand carries.
|
// MxCommandKind discriminates which MXAccess command an MxCommand carries.
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ clients/java/
|
|||||||
settings.gradle
|
settings.gradle
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/generated/
|
src/main/generated/
|
||||||
mxgateway-client/
|
zb-mom-ww-mxgateway-client/
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/java/com/dohertylan/mxgateway/client/
|
src/main/java/com/zb/mom/ww/mxgateway/client/
|
||||||
src/test/java/com/dohertylan/mxgateway/client/
|
src/test/java/com/zb/mom/ww/mxgateway/client/
|
||||||
mxgateway-cli/
|
zb-mom-ww-mxgateway-cli/
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/java/com/dohertylan/mxgateway/cli/
|
src/main/java/com/zb/mom/ww/mxgateway/cli/
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternative Maven layout is acceptable if the repo standardizes on Maven.
|
Alternative Maven layout is acceptable if the repo standardizes on Maven.
|
||||||
@@ -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:
|
||||||
@@ -192,8 +209,8 @@ stream for bounded time, and close.
|
|||||||
|
|
||||||
Publish library and CLI separately:
|
Publish library and CLI separately:
|
||||||
|
|
||||||
- `mxgateway-client` jar,
|
- `zb-mom-ww-mxgateway-client` jar,
|
||||||
- `mxgateway-cli` runnable distribution.
|
- `zb-mom-ww-mxgateway-cli` runnable distribution.
|
||||||
|
|
||||||
Generated protobuf code should be produced during the build from shared proto
|
Generated protobuf code should be produced during the build from shared proto
|
||||||
files and should not be hand-edited.
|
files and should not be hand-edited.
|
||||||
@@ -206,10 +223,10 @@ Run the Java scaffold checks from `clients/java`:
|
|||||||
gradle test
|
gradle test
|
||||||
```
|
```
|
||||||
|
|
||||||
The `mxgateway-client` project generates the gateway and worker protobuf/gRPC
|
The `zb-mom-ww-mxgateway-client` project generates the gateway and worker
|
||||||
bindings into `src/main/generated`, compiles the generated contracts, and runs
|
protobuf/gRPC bindings into `src/main/generated`, compiles the generated
|
||||||
JUnit 5 tests. The `mxgateway-cli` project builds a Picocli-based `mxgw-java`
|
contracts, and runs JUnit 5 tests. The `zb-mom-ww-mxgateway-cli` project
|
||||||
entry point for later command implementation.
|
builds a Picocli-based `mxgw-java` entry point for later command implementation.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
|
|||||||
+130
-39
@@ -10,22 +10,23 @@ clients/java/
|
|||||||
settings.gradle
|
settings.gradle
|
||||||
build.gradle
|
build.gradle
|
||||||
src/main/generated/
|
src/main/generated/
|
||||||
mxgateway-client/
|
zb-mom-ww-mxgateway-client/
|
||||||
mxgateway-cli/
|
zb-mom-ww-mxgateway-cli/
|
||||||
```
|
```
|
||||||
|
|
||||||
`mxgateway-client` generates Java protobuf and gRPC sources from
|
`zb-mom-ww-mxgateway-client` generates Java protobuf and gRPC sources from
|
||||||
`../../src/MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
|
`../../src/ZB.MOM.WW.MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
|
||||||
generated sources under `src/main/generated`, which matches the client proto
|
generated sources under `src/main/generated`, which matches the client proto
|
||||||
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
|
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
|
||||||
|
|
||||||
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
|
`zb-mom-ww-mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
|
||||||
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
|
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
|
||||||
generated stubs, and generated protobuf messages for parity tests.
|
generated stubs, and generated protobuf messages for parity tests.
|
||||||
|
|
||||||
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
|
`zb-mom-ww-mxgateway-cli` depends on `zb-mom-ww-mxgateway-client` and provides
|
||||||
application entry point. The CLI supports version, session, command, event
|
the `mxgw-java` application entry point. The CLI supports version, session,
|
||||||
streaming, write, and smoke-test commands with deterministic JSON output.
|
command, event streaming, write, and smoke-test commands with deterministic
|
||||||
|
JSON output.
|
||||||
|
|
||||||
## Regenerating Protobuf Bindings
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ Run generation from `clients/java` after the shared `.proto` files or Java
|
|||||||
output path changes:
|
output path changes:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :mxgateway-client:generateProto
|
gradle :zb-mom-ww-mxgateway-client:generateProto
|
||||||
```
|
```
|
||||||
|
|
||||||
## Client Usage
|
## Client Usage
|
||||||
@@ -56,29 +57,33 @@ 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
|
||||||
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
|
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
|
||||||
data-bearing MXAccess failure.
|
data-bearing MXAccess failure.
|
||||||
|
|
||||||
`openSession` verifies the gateway's reported `gateway_protocol_version` against
|
|
||||||
the version this client was generated for and throws `MxGatewayException` on a
|
|
||||||
mismatch, so an incompatible client fails fast with a clear message instead of
|
|
||||||
issuing commands that fail downstream. A gateway that does not populate the
|
|
||||||
field is accepted unchanged.
|
|
||||||
|
|
||||||
`MxGatewaySession` implements `AutoCloseable`. The try-with-resources `close()`
|
|
||||||
performs a `CloseSession` network RPC but swallows (and logs) any failure of
|
|
||||||
that RPC so a close-time error never replaces the exception a try-with-resources
|
|
||||||
body is already propagating. Call `closeRaw()` explicitly when you need to
|
|
||||||
observe the close result or handle a close-time failure.
|
|
||||||
|
|
||||||
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
|
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
|
||||||
cancels the underlying gRPC stream. Canceling or timing out a Java client call
|
cancels the underlying gRPC stream. Canceling or timing out a Java client call
|
||||||
only stops the client from waiting; it does not abort an in-flight MXAccess COM
|
only stops the client from waiting; it does not abort an in-flight MXAccess COM
|
||||||
call on the worker STA.
|
call on the worker STA.
|
||||||
|
|
||||||
|
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
|
||||||
|
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
|
||||||
|
yields alarm-feed messages from the gateway's central monitor), and
|
||||||
|
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
|
||||||
|
ack target). Close the subscription to cancel the underlying gRPC stream.
|
||||||
|
|
||||||
## Galaxy Repository Browse
|
## Galaxy Repository Browse
|
||||||
|
|
||||||
The Galaxy Repository service is a separate metadata-only gRPC service exposed
|
The Galaxy Repository service is a separate metadata-only gRPC service exposed
|
||||||
@@ -116,11 +121,64 @@ The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
|
|||||||
`--timeout`, and `--json` options as the gateway commands.
|
`--timeout`, and `--json` options as the gateway commands.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
gradle :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
|
||||||
@@ -168,8 +226,8 @@ The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
|
|||||||
one line per event in text mode or one JSON object per event with `--json`:
|
one line per event in text mode or one JSON object per event with `--json`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI Usage
|
## CLI Usage
|
||||||
@@ -177,14 +235,16 @@ gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-k
|
|||||||
Run the CLI through Gradle:
|
Run the CLI through Gradle:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :mxgateway-cli:run --args="version --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
|
||||||
gradle :mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
|
||||||
gradle :mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
|
||||||
gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
|
||||||
gradle :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 :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 :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 :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="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
|
||||||
|
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
||||||
|
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
|
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
|
||||||
@@ -194,7 +254,7 @@ output redacts API keys.
|
|||||||
Use TLS options for a secured gateway:
|
Use TLS options for a secured gateway:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
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 TestObject.TestInt --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 TestObject.TestInt --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build And Test
|
## Build And Test
|
||||||
@@ -214,11 +274,11 @@ in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
|
|||||||
Create local library and CLI artifacts from `clients/java`:
|
Create local library and CLI artifacts from `clients/java`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
gradle :mxgateway-client:jar :mxgateway-cli:installDist
|
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
|
||||||
```
|
```
|
||||||
|
|
||||||
The library jar is under `mxgateway-client/build/libs`. The installed CLI
|
The library jar is under `zb-mom-ww-mxgateway-client/build/libs`. The installed CLI
|
||||||
distribution is under `mxgateway-cli/build/install/mxgateway-cli`.
|
distribution is under `zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli`.
|
||||||
|
|
||||||
## Integration Checks
|
## Integration Checks
|
||||||
|
|
||||||
@@ -229,9 +289,40 @@ $env:MXGATEWAY_INTEGRATION = '1'
|
|||||||
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
|
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
|
||||||
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
||||||
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
||||||
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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
group = 'com.dohertylan.mxgateway'
|
group = 'com.zb.mom.ww.mxgateway'
|
||||||
version = '0.1.0'
|
version = '0.1.0'
|
||||||
|
|
||||||
pluginManager.withPlugin('java') {
|
pluginManager.withPlugin('java') {
|
||||||
@@ -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') ?: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-985
@@ -1,985 +0,0 @@
|
|||||||
package com.dohertylan.mxgateway.cli;
|
|
||||||
|
|
||||||
import com.dohertylan.mxgateway.client.DeployEventStream;
|
|
||||||
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
|
|
||||||
import com.dohertylan.mxgateway.client.MxEventStream;
|
|
||||||
import com.dohertylan.mxgateway.client.MxGatewayClient;
|
|
||||||
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
|
|
||||||
import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
|
|
||||||
import com.dohertylan.mxgateway.client.MxGatewaySecrets;
|
|
||||||
import com.dohertylan.mxgateway.client.MxGatewaySession;
|
|
||||||
import com.dohertylan.mxgateway.client.MxValues;
|
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
|
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
|
||||||
import com.google.protobuf.Message;
|
|
||||||
import com.google.protobuf.util.JsonFormat;
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.Callable;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
|
||||||
import picocli.CommandLine;
|
|
||||||
import picocli.CommandLine.Command;
|
|
||||||
import picocli.CommandLine.Mixin;
|
|
||||||
import picocli.CommandLine.Model.CommandSpec;
|
|
||||||
import picocli.CommandLine.Option;
|
|
||||||
import picocli.CommandLine.Spec;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picocli entry point for the {@code mxgw-java} test CLI used by the
|
|
||||||
* cross-language smoke matrix.
|
|
||||||
*/
|
|
||||||
@Command(
|
|
||||||
name = "mxgw-java",
|
|
||||||
mixinStandardHelpOptions = true,
|
|
||||||
description = "MXAccess Gateway Java test CLI.")
|
|
||||||
public final class MxGatewayCli implements Callable<Integer> {
|
|
||||||
private final MxGatewayCliClientFactory clientFactory;
|
|
||||||
|
|
||||||
@Spec
|
|
||||||
private CommandSpec spec;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a CLI bound to the default gRPC client factory.
|
|
||||||
*/
|
|
||||||
public MxGatewayCli() {
|
|
||||||
this(new GrpcMxGatewayCliClientFactory());
|
|
||||||
}
|
|
||||||
|
|
||||||
MxGatewayCli(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
this.clientFactory = clientFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process entry point.
|
|
||||||
*
|
|
||||||
* @param args command-line arguments
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) {
|
|
||||||
int exitCode = commandLine(new GrpcMxGatewayCliClientFactory()).execute(args);
|
|
||||||
System.exit(exitCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test-friendly entry point that runs the CLI against the supplied
|
|
||||||
* {@link PrintWriter} pair instead of the system streams.
|
|
||||||
*
|
|
||||||
* @param out writer that receives standard output
|
|
||||||
* @param err writer that receives standard error
|
|
||||||
* @param args command-line arguments
|
|
||||||
* @return the picocli exit code
|
|
||||||
*/
|
|
||||||
public static int execute(PrintWriter out, PrintWriter err, String... args) {
|
|
||||||
return execute(new GrpcMxGatewayCliClientFactory(), out, err, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int execute(MxGatewayCliClientFactory clientFactory, PrintWriter out, PrintWriter err, String... args) {
|
|
||||||
CommandLine commandLine = commandLine(clientFactory);
|
|
||||||
commandLine.setOut(out);
|
|
||||||
commandLine.setErr(err);
|
|
||||||
return commandLine.execute(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
spec.commandLine().usage(spec.commandLine().getOut());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
|
|
||||||
commandLine.addSubcommand("version", new VersionCommand());
|
|
||||||
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("close-session", new CloseSessionCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("register", new RegisterCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
|
|
||||||
commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand());
|
|
||||||
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
|
|
||||||
commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand());
|
|
||||||
commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand());
|
|
||||||
return commandLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract static class GalaxyCommand implements Callable<Integer> {
|
|
||||||
@Mixin
|
|
||||||
CommonOptions common = new CommonOptions();
|
|
||||||
|
|
||||||
@Option(names = "--json", description = "Write JSON output.")
|
|
||||||
boolean json;
|
|
||||||
|
|
||||||
GalaxyRepositoryClient connect() {
|
|
||||||
return GalaxyRepositoryClient.connect(common.resolved().toClientOptions());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "galaxy-test", description = "Calls GalaxyRepository.TestConnection.")
|
|
||||||
static final class GalaxyTestConnectionCommand extends GalaxyCommand {
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (GalaxyRepositoryClient client = connect()) {
|
|
||||||
boolean ok = client.testConnection();
|
|
||||||
PrintWriter out = common.spec.commandLine().getOut();
|
|
||||||
if (json) {
|
|
||||||
Map<String, Object> output = new LinkedHashMap<>();
|
|
||||||
output.put("command", "galaxy-test");
|
|
||||||
output.put("options", common.redactedJsonMap());
|
|
||||||
output.put("ok", ok);
|
|
||||||
out.println(jsonObject(output));
|
|
||||||
} else {
|
|
||||||
out.println(ok);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "galaxy-deploy-time", description = "Calls GalaxyRepository.GetLastDeployTime.")
|
|
||||||
static final class GalaxyDeployTimeCommand extends GalaxyCommand {
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (GalaxyRepositoryClient client = connect()) {
|
|
||||||
Optional<Instant> result = client.getLastDeployTime();
|
|
||||||
PrintWriter out = common.spec.commandLine().getOut();
|
|
||||||
if (json) {
|
|
||||||
Map<String, Object> output = new LinkedHashMap<>();
|
|
||||||
output.put("command", "galaxy-deploy-time");
|
|
||||||
output.put("options", common.redactedJsonMap());
|
|
||||||
output.put("present", result.isPresent());
|
|
||||||
output.put("timeOfLastDeploy", result.map(Instant::toString).orElse(""));
|
|
||||||
out.println(jsonObject(output));
|
|
||||||
} else if (result.isPresent()) {
|
|
||||||
out.println(result.get());
|
|
||||||
} else {
|
|
||||||
out.println("(none)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "galaxy-discover", description = "Calls GalaxyRepository.DiscoverHierarchy.")
|
|
||||||
static final class GalaxyDiscoverCommand extends GalaxyCommand {
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (GalaxyRepositoryClient client = connect()) {
|
|
||||||
List<GalaxyObject> objects = client.discoverHierarchy();
|
|
||||||
PrintWriter out = common.spec.commandLine().getOut();
|
|
||||||
if (json) {
|
|
||||||
Map<String, Object> output = new LinkedHashMap<>();
|
|
||||||
output.put("command", "galaxy-discover");
|
|
||||||
output.put("options", common.redactedJsonMap());
|
|
||||||
output.put("objects", objects.stream().map(MxGatewayCli::galaxyObjectMap).toList());
|
|
||||||
out.println(jsonObject(output));
|
|
||||||
} else {
|
|
||||||
out.printf("count=%d%n", objects.size());
|
|
||||||
for (GalaxyObject obj : objects) {
|
|
||||||
out.printf(" %s [%s] attrs=%d%n",
|
|
||||||
obj.getTagName(), obj.getBrowseName(), obj.getAttributesCount());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(
|
|
||||||
name = "galaxy-watch",
|
|
||||||
description = "Streams GalaxyRepository.WatchDeployEvents until cancelled.")
|
|
||||||
static final class GalaxyWatchCommand extends GalaxyCommand {
|
|
||||||
@Option(
|
|
||||||
names = "--last-seen-deploy-time",
|
|
||||||
description =
|
|
||||||
"Optional ISO-8601 instant. When supplied, the bootstrap event is suppressed if the cached"
|
|
||||||
+ " deploy time matches.")
|
|
||||||
String lastSeenDeployTime;
|
|
||||||
|
|
||||||
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print before exiting.")
|
|
||||||
int limit;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
Instant after = parseInstant(lastSeenDeployTime);
|
|
||||||
try (GalaxyRepositoryClient client = connect();
|
|
||||||
DeployEventStream events = client.watchDeployEvents(after)) {
|
|
||||||
PrintWriter out = common.spec.commandLine().getOut();
|
|
||||||
Thread shutdownHook = new Thread(events::close, "galaxy-watch-shutdown");
|
|
||||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
|
||||||
try {
|
|
||||||
int count = 0;
|
|
||||||
while (events.hasNext()) {
|
|
||||||
DeployEvent event = events.next();
|
|
||||||
if (json) {
|
|
||||||
out.println(protoJson(event));
|
|
||||||
} else {
|
|
||||||
out.printf(
|
|
||||||
"seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
|
|
||||||
event.getSequence(),
|
|
||||||
formatTimestamp(event.getObservedAt()),
|
|
||||||
event.getTimeOfLastDeployPresent()
|
|
||||||
? formatTimestamp(event.getTimeOfLastDeploy())
|
|
||||||
: "(none)",
|
|
||||||
event.getObjectCount(),
|
|
||||||
event.getAttributeCount());
|
|
||||||
}
|
|
||||||
out.flush();
|
|
||||||
count++;
|
|
||||||
if (limit > 0 && count >= limit) {
|
|
||||||
events.close();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
|
||||||
} catch (IllegalStateException ignored) {
|
|
||||||
// JVM is already shutting down.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Instant parseInstant(String value) {
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Instant.parse(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String formatTimestamp(com.google.protobuf.Timestamp ts) {
|
|
||||||
return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()).toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> galaxyObjectMap(GalaxyObject obj) {
|
|
||||||
Map<String, Object> values = new LinkedHashMap<>();
|
|
||||||
values.put("gobjectId", obj.getGobjectId());
|
|
||||||
values.put("tagName", obj.getTagName());
|
|
||||||
values.put("containedName", obj.getContainedName());
|
|
||||||
values.put("browseName", obj.getBrowseName());
|
|
||||||
values.put("parentGobjectId", obj.getParentGobjectId());
|
|
||||||
values.put("isArea", obj.getIsArea());
|
|
||||||
values.put("categoryId", obj.getCategoryId());
|
|
||||||
values.put("hostedByGobjectId", obj.getHostedByGobjectId());
|
|
||||||
values.put("templateChain", new ArrayList<>(obj.getTemplateChainList()));
|
|
||||||
List<Map<String, Object>> attrs = new ArrayList<>();
|
|
||||||
for (GalaxyAttribute attr : obj.getAttributesList()) {
|
|
||||||
Map<String, Object> attrMap = new LinkedHashMap<>();
|
|
||||||
attrMap.put("attributeName", attr.getAttributeName());
|
|
||||||
attrMap.put("fullTagReference", attr.getFullTagReference());
|
|
||||||
attrMap.put("mxDataType", attr.getMxDataType());
|
|
||||||
attrMap.put("dataTypeName", attr.getDataTypeName());
|
|
||||||
attrMap.put("isArray", attr.getIsArray());
|
|
||||||
attrMap.put("arrayDimension", attr.getArrayDimension());
|
|
||||||
attrMap.put("arrayDimensionPresent", attr.getArrayDimensionPresent());
|
|
||||||
attrMap.put("mxAttributeCategory", attr.getMxAttributeCategory());
|
|
||||||
attrMap.put("securityClassification", attr.getSecurityClassification());
|
|
||||||
attrMap.put("isHistorized", attr.getIsHistorized());
|
|
||||||
attrMap.put("isAlarm", attr.getIsAlarm());
|
|
||||||
attrs.add(attrMap);
|
|
||||||
}
|
|
||||||
values.put("attributes", attrs);
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picocli subcommand that prints the client and protocol version numbers.
|
|
||||||
*/
|
|
||||||
@Command(name = "version", description = "Prints the Java client version.")
|
|
||||||
public static final class VersionCommand implements Callable<Integer> {
|
|
||||||
@Spec
|
|
||||||
private CommandSpec spec;
|
|
||||||
|
|
||||||
@Option(names = "--json", description = "Write JSON output.")
|
|
||||||
private boolean json;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
Map<String, Object> values = new LinkedHashMap<>();
|
|
||||||
values.put("clientVersion", MxGatewayClientVersion.clientVersion());
|
|
||||||
values.put("gatewayProtocolVersion", MxGatewayClientVersion.gatewayProtocolVersion());
|
|
||||||
values.put("workerProtocolVersion", MxGatewayClientVersion.workerProtocolVersion());
|
|
||||||
if (json) {
|
|
||||||
spec.commandLine().getOut().println(jsonObject(values));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
spec.commandLine()
|
|
||||||
.getOut()
|
|
||||||
.printf(
|
|
||||||
"mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
|
|
||||||
MxGatewayClientVersion.clientVersion(),
|
|
||||||
MxGatewayClientVersion.gatewayProtocolVersion(),
|
|
||||||
MxGatewayClientVersion.workerProtocolVersion());
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract static class GatewayCommand implements Callable<Integer> {
|
|
||||||
final MxGatewayCliClientFactory clientFactory;
|
|
||||||
|
|
||||||
@Mixin
|
|
||||||
CommonOptions common = new CommonOptions();
|
|
||||||
|
|
||||||
@Option(names = "--json", description = "Write JSON output.")
|
|
||||||
boolean json;
|
|
||||||
|
|
||||||
GatewayCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
this.clientFactory = clientFactory;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "open-session", description = "Opens a gateway session.")
|
|
||||||
static final class OpenSessionCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--client-session-name", description = "Client session name.")
|
|
||||||
String clientSessionName = "";
|
|
||||||
|
|
||||||
@Option(names = "--backend", description = "Requested gateway backend.")
|
|
||||||
String backend = "";
|
|
||||||
|
|
||||||
OpenSessionCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
var reply = client.openSession(OpenSessionRequest.newBuilder()
|
|
||||||
.setClientSessionName(clientSessionName)
|
|
||||||
.setRequestedBackend(backend)
|
|
||||||
.build());
|
|
||||||
writeOutput("open-session", common, json, reply, () -> reply.getSessionId());
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "close-session", description = "Closes a gateway session.")
|
|
||||||
static final class CloseSessionCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
CloseSessionCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
var reply = client.closeSession(CloseSessionRequest.newBuilder()
|
|
||||||
.setSessionId(sessionId)
|
|
||||||
.build());
|
|
||||||
writeOutput("close-session", common, json, reply, () -> reply.getFinalState().name());
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "register", description = "Invokes MXAccess Register.")
|
|
||||||
static final class RegisterCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
@Option(names = "--client-name", required = true, description = "MXAccess client name.")
|
|
||||||
String clientName;
|
|
||||||
|
|
||||||
RegisterCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
MxCommandReply reply = client.session(sessionId).registerRaw(clientName);
|
|
||||||
writeOutput("register", common, json, reply, () -> reply.getKind().name());
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "add-item", description = "Invokes MXAccess AddItem.")
|
|
||||||
static final class AddItemCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
|
||||||
int serverHandle;
|
|
||||||
|
|
||||||
@Option(names = "--item", required = true, description = "Item definition.")
|
|
||||||
String item;
|
|
||||||
|
|
||||||
AddItemCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
MxCommandReply reply = client.session(sessionId).addItemRaw(serverHandle, item);
|
|
||||||
writeOutput("add-item", common, json, reply, () -> reply.getKind().name());
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "advise", description = "Invokes MXAccess Advise.")
|
|
||||||
static final class AdviseCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
|
||||||
int serverHandle;
|
|
||||||
|
|
||||||
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
|
|
||||||
int itemHandle;
|
|
||||||
|
|
||||||
AdviseCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
MxCommandReply reply = client.session(sessionId).adviseRaw(serverHandle, itemHandle);
|
|
||||||
writeOutput("advise", common, json, reply, () -> reply.getKind().name());
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
|
|
||||||
static final class SubscribeBulkCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
|
||||||
int serverHandle;
|
|
||||||
|
|
||||||
@Option(names = "--items", required = true, description = "Comma-separated item definitions.")
|
|
||||||
String items;
|
|
||||||
|
|
||||||
SubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
List<SubscribeResult> results =
|
|
||||||
client.session(sessionId).subscribeBulk(serverHandle, parseStringList(items));
|
|
||||||
writeBulkOutput("subscribe-bulk", common, json, results);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "unsubscribe-bulk", description = "Invokes MXAccess UnsubscribeBulk.")
|
|
||||||
static final class UnsubscribeBulkCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
|
||||||
int serverHandle;
|
|
||||||
|
|
||||||
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
|
|
||||||
String itemHandles;
|
|
||||||
|
|
||||||
UnsubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
List<SubscribeResult> results =
|
|
||||||
client.session(sessionId).unsubscribeBulk(serverHandle, parseIntList(itemHandles));
|
|
||||||
writeBulkOutput("unsubscribe-bulk", common, json, results);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "write", description = "Invokes MXAccess Write.")
|
|
||||||
static final class WriteCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
|
||||||
int serverHandle;
|
|
||||||
|
|
||||||
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
|
|
||||||
int itemHandle;
|
|
||||||
|
|
||||||
@Option(names = "--type", defaultValue = "string", description = "Value type.")
|
|
||||||
String type;
|
|
||||||
|
|
||||||
@Option(names = "--value", required = true, description = "Value text.")
|
|
||||||
String value;
|
|
||||||
|
|
||||||
@Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
|
|
||||||
int userId;
|
|
||||||
|
|
||||||
WriteCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
MxCommandReply reply =
|
|
||||||
client.session(sessionId).writeRaw(serverHandle, itemHandle, parseValue(type, value), userId);
|
|
||||||
writeOutput("write", common, json, reply, () -> reply.getKind().name());
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "stream-events", description = "Streams gateway events.")
|
|
||||||
static final class StreamEventsCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
|
||||||
String sessionId;
|
|
||||||
|
|
||||||
@Option(names = "--after-worker-sequence", defaultValue = "0", description = "Starting worker sequence.")
|
|
||||||
long afterWorkerSequence;
|
|
||||||
|
|
||||||
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print.")
|
|
||||||
int limit;
|
|
||||||
|
|
||||||
StreamEventsCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved());
|
|
||||||
MxEventStream events = client.session(sessionId).streamEventsAfter(afterWorkerSequence)) {
|
|
||||||
int count = 0;
|
|
||||||
while (events.hasNext()) {
|
|
||||||
MxEvent event = events.next();
|
|
||||||
if (json) {
|
|
||||||
client.out().println(protoJson(event));
|
|
||||||
} else {
|
|
||||||
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
|
|
||||||
}
|
|
||||||
count++;
|
|
||||||
if (limit > 0 && count >= limit) {
|
|
||||||
events.close();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Command(name = "smoke", description = "Runs a bounded open/register/add/advise flow.")
|
|
||||||
static final class SmokeCommand extends GatewayCommand {
|
|
||||||
@Option(names = "--client-name", defaultValue = "mxgw-java-smoke", description = "MXAccess client name.")
|
|
||||||
String clientName;
|
|
||||||
|
|
||||||
@Option(names = "--item", required = true, description = "Item definition.")
|
|
||||||
String item;
|
|
||||||
|
|
||||||
SmokeCommand(MxGatewayCliClientFactory clientFactory) {
|
|
||||||
super(clientFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer call() {
|
|
||||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
|
||||||
var session = client.openSession(OpenSessionRequest.newBuilder()
|
|
||||||
.setClientSessionName(clientName)
|
|
||||||
.build());
|
|
||||||
try {
|
|
||||||
MxGatewayCliSession cliSession = client.session(session.getSessionId());
|
|
||||||
int serverHandle = cliSession.register(clientName);
|
|
||||||
int itemHandle = cliSession.addItem(serverHandle, item);
|
|
||||||
cliSession.advise(serverHandle, itemHandle);
|
|
||||||
if (json) {
|
|
||||||
Map<String, Object> output = new LinkedHashMap<>();
|
|
||||||
output.put("command", "smoke");
|
|
||||||
output.put("options", common.redactedJsonMap());
|
|
||||||
output.put("sessionId", session.getSessionId());
|
|
||||||
output.put("serverHandle", serverHandle);
|
|
||||||
output.put("itemHandle", itemHandle);
|
|
||||||
client.out().println(jsonObject(output));
|
|
||||||
} else {
|
|
||||||
client.out().printf(
|
|
||||||
"session=%s server=%d item=%d%n", session.getSessionId(), serverHandle, itemHandle);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
client.closeSession(CloseSessionRequest.newBuilder()
|
|
||||||
.setSessionId(session.getSessionId())
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static final class CommonOptions {
|
|
||||||
@Spec
|
|
||||||
CommandSpec spec;
|
|
||||||
|
|
||||||
@Option(names = "--endpoint", defaultValue = "localhost:5000", description = "Gateway endpoint.")
|
|
||||||
String endpoint;
|
|
||||||
|
|
||||||
@Option(names = "--api-key", description = "Gateway API key.")
|
|
||||||
String apiKey = "";
|
|
||||||
|
|
||||||
@Option(names = "--api-key-env", defaultValue = "MXGATEWAY_API_KEY", description = "API key environment variable.")
|
|
||||||
String apiKeyEnv;
|
|
||||||
|
|
||||||
@Option(names = "--plaintext", description = "Use plaintext transport.")
|
|
||||||
boolean plaintext;
|
|
||||||
|
|
||||||
@Option(names = "--ca-file", description = "CA certificate file.")
|
|
||||||
Path caFile;
|
|
||||||
|
|
||||||
@Option(names = "--server-name-override", description = "TLS server name override.")
|
|
||||||
String serverNameOverride = "";
|
|
||||||
|
|
||||||
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
|
|
||||||
String timeout;
|
|
||||||
|
|
||||||
private String resolvedApiKey = "";
|
|
||||||
private Duration resolvedTimeout = Duration.ofSeconds(30);
|
|
||||||
|
|
||||||
CommonOptions resolved() {
|
|
||||||
resolvedApiKey = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
|
|
||||||
if (resolvedApiKey == null) {
|
|
||||||
resolvedApiKey = "";
|
|
||||||
}
|
|
||||||
resolvedTimeout = parseDuration(timeout);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
MxGatewayClientOptions toClientOptions() {
|
|
||||||
return MxGatewayClientOptions.builder()
|
|
||||||
.endpoint(endpoint)
|
|
||||||
.apiKey(resolvedApiKey)
|
|
||||||
.plaintext(plaintext)
|
|
||||||
.caCertificatePath(caFile)
|
|
||||||
.serverNameOverride(serverNameOverride)
|
|
||||||
.callTimeout(resolvedTimeout)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> redactedJsonMap() {
|
|
||||||
Map<String, Object> values = new LinkedHashMap<>();
|
|
||||||
values.put("endpoint", endpoint);
|
|
||||||
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey));
|
|
||||||
values.put("apiKeyEnv", apiKeyEnv);
|
|
||||||
values.put("plaintext", plaintext);
|
|
||||||
values.put("caFile", caFile == null ? "" : caFile.toString());
|
|
||||||
values.put("serverNameOverride", serverNameOverride);
|
|
||||||
values.put("timeout", timeout);
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MxGatewayCliClientFactory {
|
|
||||||
MxGatewayCliClient connect(CommonOptions options);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MxGatewayCliClient extends AutoCloseable {
|
|
||||||
PrintWriter out();
|
|
||||||
|
|
||||||
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request);
|
|
||||||
|
|
||||||
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request);
|
|
||||||
|
|
||||||
MxGatewayCliSession session(String sessionId);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
void close();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MxGatewayCliSession {
|
|
||||||
int register(String clientName);
|
|
||||||
|
|
||||||
MxCommandReply registerRaw(String clientName);
|
|
||||||
|
|
||||||
int addItem(int serverHandle, String itemDefinition);
|
|
||||||
|
|
||||||
MxCommandReply addItemRaw(int serverHandle, String itemDefinition);
|
|
||||||
|
|
||||||
void advise(int serverHandle, int itemHandle);
|
|
||||||
|
|
||||||
MxCommandReply adviseRaw(int serverHandle, int itemHandle);
|
|
||||||
|
|
||||||
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
|
|
||||||
|
|
||||||
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
|
|
||||||
|
|
||||||
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
|
|
||||||
|
|
||||||
MxEventStream streamEventsAfter(long afterWorkerSequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
static final class GrpcMxGatewayCliClientFactory implements MxGatewayCliClientFactory {
|
|
||||||
@Override
|
|
||||||
public MxGatewayCliClient connect(CommonOptions options) {
|
|
||||||
return new GrpcMxGatewayCliClient(MxGatewayClient.connect(options.toClientOptions()), options.spec.commandLine().getOut());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static final class GrpcMxGatewayCliClient implements MxGatewayCliClient {
|
|
||||||
private final MxGatewayClient client;
|
|
||||||
private final PrintWriter out;
|
|
||||||
|
|
||||||
GrpcMxGatewayCliClient(MxGatewayClient client, PrintWriter out) {
|
|
||||||
this.client = client;
|
|
||||||
this.out = out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public PrintWriter out() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request) {
|
|
||||||
return client.openSessionRaw(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request) {
|
|
||||||
return client.closeSessionRaw(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxGatewayCliSession session(String sessionId) {
|
|
||||||
return new GrpcMxGatewayCliSession(MxGatewaySession.forSessionId(client, sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
record GrpcMxGatewayCliSession(MxGatewaySession session) implements MxGatewayCliSession {
|
|
||||||
@Override
|
|
||||||
public int register(String clientName) {
|
|
||||||
return session.register(clientName);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply registerRaw(String clientName) {
|
|
||||||
return session.registerRaw(clientName);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int addItem(int serverHandle, String itemDefinition) {
|
|
||||||
return session.addItem(serverHandle, itemDefinition);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
|
|
||||||
return session.addItemRaw(serverHandle, itemDefinition);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void advise(int serverHandle, int itemHandle) {
|
|
||||||
session.advise(serverHandle, itemHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
|
|
||||||
return session.adviseRaw(serverHandle, itemHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
|
|
||||||
return session.writeRaw(serverHandle, itemHandle, value, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
|
|
||||||
return session.subscribeBulk(serverHandle, items);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
|
|
||||||
return session.unsubscribeBulk(serverHandle, itemHandles);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
|
||||||
return session.streamEventsAfter(afterWorkerSequence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextSupplier {
|
|
||||||
String get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void writeOutput(
|
|
||||||
String command, CommonOptions common, boolean json, Message reply, TextSupplier textSupplier) {
|
|
||||||
PrintWriter out = common.spec.commandLine().getOut();
|
|
||||||
if (json) {
|
|
||||||
Map<String, Object> output = new LinkedHashMap<>();
|
|
||||||
output.put("command", command);
|
|
||||||
output.put("options", common.redactedJsonMap());
|
|
||||||
output.put("reply", new RawJson(protoJson(reply)));
|
|
||||||
out.println(jsonObject(output));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
out.println(textSupplier.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void writeBulkOutput(
|
|
||||||
String command, CommonOptions common, boolean json, List<SubscribeResult> results) {
|
|
||||||
PrintWriter out = common.spec.commandLine().getOut();
|
|
||||||
if (json) {
|
|
||||||
Map<String, Object> output = new LinkedHashMap<>();
|
|
||||||
output.put("command", command);
|
|
||||||
output.put("options", common.redactedJsonMap());
|
|
||||||
output.put("results", results.stream().map(MxGatewayCli::subscribeResultMap).toList());
|
|
||||||
out.println(jsonObject(output));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
out.println(results.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> subscribeResultMap(SubscribeResult result) {
|
|
||||||
Map<String, Object> values = new LinkedHashMap<>();
|
|
||||||
values.put("serverHandle", result.getServerHandle());
|
|
||||||
values.put("tagAddress", result.getTagAddress());
|
|
||||||
values.put("itemHandle", result.getItemHandle());
|
|
||||||
values.put("wasSuccessful", result.getWasSuccessful());
|
|
||||||
values.put("errorMessage", result.getErrorMessage());
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MxValue parseValue(String type, String text) {
|
|
||||||
return switch (type) {
|
|
||||||
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
|
|
||||||
case "int32" -> MxValues.int32Value(Integer.parseInt(text));
|
|
||||||
case "int64" -> MxValues.int64Value(Long.parseLong(text));
|
|
||||||
case "float" -> MxValues.floatValue(Float.parseFloat(text));
|
|
||||||
case "double" -> MxValues.doubleValue(Double.parseDouble(text));
|
|
||||||
case "string" -> MxValues.stringValue(text);
|
|
||||||
default -> throw new IllegalArgumentException("unsupported value type " + type);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> parseStringList(String value) {
|
|
||||||
return Arrays.stream(value.split(","))
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(item -> !item.isBlank())
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Integer> parseIntList(String value) {
|
|
||||||
return parseStringList(value).stream().map(Integer::parseInt).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Duration parseDuration(String value) {
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return Duration.ofSeconds(30);
|
|
||||||
}
|
|
||||||
if (value.startsWith("P")) {
|
|
||||||
return Duration.parse(value);
|
|
||||||
}
|
|
||||||
if (value.endsWith("ms")) {
|
|
||||||
return Duration.ofMillis(Long.parseLong(value.substring(0, value.length() - 2)));
|
|
||||||
}
|
|
||||||
if (value.endsWith("s")) {
|
|
||||||
return Duration.ofSeconds(Long.parseLong(value.substring(0, value.length() - 1)));
|
|
||||||
}
|
|
||||||
if (value.endsWith("m")) {
|
|
||||||
return Duration.ofMinutes(Long.parseLong(value.substring(0, value.length() - 1)));
|
|
||||||
}
|
|
||||||
return Duration.parse(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String protoJson(Message message) {
|
|
||||||
try {
|
|
||||||
return JsonFormat.printer().omittingInsignificantWhitespace().print(message);
|
|
||||||
} catch (Exception error) {
|
|
||||||
throw new IllegalStateException("failed to write protobuf JSON", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String jsonObject(Map<String, Object> values) {
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
builder.append('{');
|
|
||||||
boolean first = true;
|
|
||||||
for (Map.Entry<String, Object> entry : values.entrySet()) {
|
|
||||||
if (!first) {
|
|
||||||
builder.append(',');
|
|
||||||
}
|
|
||||||
first = false;
|
|
||||||
builder.append(jsonString(entry.getKey())).append(':').append(jsonValue(entry.getValue()));
|
|
||||||
}
|
|
||||||
builder.append('}');
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private static String jsonValue(Object value) {
|
|
||||||
if (value == null) {
|
|
||||||
return "null";
|
|
||||||
}
|
|
||||||
if (value instanceof RawJson rawJson) {
|
|
||||||
return rawJson.value();
|
|
||||||
}
|
|
||||||
if (value instanceof String string) {
|
|
||||||
return jsonString(string);
|
|
||||||
}
|
|
||||||
if (value instanceof Number || value instanceof Boolean) {
|
|
||||||
return value.toString();
|
|
||||||
}
|
|
||||||
if (value instanceof Map<?, ?> map) {
|
|
||||||
return jsonObject((Map<String, Object>) map);
|
|
||||||
}
|
|
||||||
if (value instanceof Iterable<?> iterable) {
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
builder.append('[');
|
|
||||||
boolean first = true;
|
|
||||||
for (Object item : iterable) {
|
|
||||||
if (!first) {
|
|
||||||
builder.append(',');
|
|
||||||
}
|
|
||||||
first = false;
|
|
||||||
builder.append(jsonValue(item));
|
|
||||||
}
|
|
||||||
builder.append(']');
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
return jsonString(value.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String jsonString(String value) {
|
|
||||||
return '"'
|
|
||||||
+ value.replace("\\", "\\\\")
|
|
||||||
.replace("\"", "\\\"")
|
|
||||||
.replace("\r", "\\r")
|
|
||||||
.replace("\n", "\\n")
|
|
||||||
+ '"';
|
|
||||||
}
|
|
||||||
|
|
||||||
private record RawJson(String value) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-311
@@ -1,311 +0,0 @@
|
|||||||
package com.dohertylan.mxgateway.cli;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.io.StringWriter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
|
||||||
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 mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
final class MxGatewayCliTests {
|
|
||||||
@Test
|
|
||||||
void versionCommandPrintsProtocolVersions() {
|
|
||||||
CliRun run = execute(new FakeClientFactory(), "version");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertEquals("", run.errors());
|
|
||||||
assertTrue(run.output().contains("mxgateway-java 0.1.0"));
|
|
||||||
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
|
|
||||||
assertTrue(run.output().contains("workerProtocolVersion=1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void versionCommandPrintsJson() {
|
|
||||||
CliRun run = execute(new FakeClientFactory(), "version", "--json");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertTrue(run.output().contains("\"clientVersion\":\"0.1.0\""));
|
|
||||||
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void openSessionJsonRedactsApiKey() {
|
|
||||||
CliRun run = execute(
|
|
||||||
new FakeClientFactory(),
|
|
||||||
"open-session",
|
|
||||||
"--endpoint",
|
|
||||||
"localhost:5000",
|
|
||||||
"--api-key",
|
|
||||||
"mxgw_visible_secret",
|
|
||||||
"--plaintext",
|
|
||||||
"--client-session-name",
|
|
||||||
"java-cli",
|
|
||||||
"--json");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertTrue(run.output().contains("\"command\":\"open-session\""));
|
|
||||||
assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
|
|
||||||
// Only the non-secret mxgw_<key-id>_ prefix survives; the secret is fully masked.
|
|
||||||
assertTrue(run.output().contains("mxgw_visible_***"));
|
|
||||||
assertFalse(run.output().contains("visible_secret"));
|
|
||||||
assertFalse(run.output().contains("cret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void writeBuildsTypedValueFromParserOptions() {
|
|
||||||
FakeClientFactory factory = new FakeClientFactory();
|
|
||||||
CliRun run = execute(
|
|
||||||
factory,
|
|
||||||
"write",
|
|
||||||
"--session-id",
|
|
||||||
"session-cli",
|
|
||||||
"--server-handle",
|
|
||||||
"12",
|
|
||||||
"--item-handle",
|
|
||||||
"34",
|
|
||||||
"--type",
|
|
||||||
"int32",
|
|
||||||
"--value",
|
|
||||||
"123",
|
|
||||||
"--json");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertEquals(123, factory.client.session.lastWriteValue.getInt32Value());
|
|
||||||
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void smokeCommandRunsOpenRegisterAddAdviseAndClose() {
|
|
||||||
FakeClientFactory factory = new FakeClientFactory();
|
|
||||||
CliRun run = execute(factory, "smoke", "--item", "TestObject.TestInt", "--json");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertTrue(factory.client.session.registerCalled);
|
|
||||||
assertTrue(factory.client.session.addItemCalled);
|
|
||||||
assertTrue(factory.client.session.adviseCalled);
|
|
||||||
assertTrue(factory.client.closeCalled);
|
|
||||||
assertTrue(run.output().contains("\"serverHandle\":42"));
|
|
||||||
assertTrue(run.output().contains("\"itemHandle\":7"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void subscribeBulkCommandPrintsResults() {
|
|
||||||
CliRun run = execute(
|
|
||||||
new FakeClientFactory(),
|
|
||||||
"subscribe-bulk",
|
|
||||||
"--session-id",
|
|
||||||
"session-cli",
|
|
||||||
"--server-handle",
|
|
||||||
"42",
|
|
||||||
"--items",
|
|
||||||
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
|
|
||||||
"--json");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertTrue(run.output().contains("\"command\":\"subscribe-bulk\""));
|
|
||||||
assertTrue(run.output().contains("\"itemHandle\":100"));
|
|
||||||
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void unsubscribeBulkCommandPrintsResults() {
|
|
||||||
CliRun run = execute(
|
|
||||||
new FakeClientFactory(),
|
|
||||||
"unsubscribe-bulk",
|
|
||||||
"--session-id",
|
|
||||||
"session-cli",
|
|
||||||
"--server-handle",
|
|
||||||
"42",
|
|
||||||
"--item-handles",
|
|
||||||
"100,101",
|
|
||||||
"--json");
|
|
||||||
|
|
||||||
assertEquals(0, run.exitCode());
|
|
||||||
assertTrue(run.output().contains("\"command\":\"unsubscribe-bulk\""));
|
|
||||||
assertTrue(run.output().contains("\"itemHandle\":101"));
|
|
||||||
assertTrue(run.output().contains("\"wasSuccessful\":true"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
|
|
||||||
StringWriter output = new StringWriter();
|
|
||||||
StringWriter errors = new StringWriter();
|
|
||||||
int exitCode = MxGatewayCli.execute(
|
|
||||||
factory,
|
|
||||||
new PrintWriter(output, true),
|
|
||||||
new PrintWriter(errors, true),
|
|
||||||
args);
|
|
||||||
return new CliRun(exitCode, output.toString(), errors.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private record CliRun(int exitCode, String output, String errors) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class FakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
|
||||||
private FakeClient client;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
|
||||||
client = new FakeClient(options.spec.commandLine().getOut());
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
|
||||||
private final PrintWriter out;
|
|
||||||
private final FakeSession session = new FakeSession();
|
|
||||||
private boolean closeCalled;
|
|
||||||
|
|
||||||
private FakeClient(PrintWriter out) {
|
|
||||||
this.out = out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public PrintWriter out() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public OpenSessionReply openSession(OpenSessionRequest request) {
|
|
||||||
return OpenSessionReply.newBuilder()
|
|
||||||
.setSessionId("session-cli")
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public CloseSessionReply closeSession(CloseSessionRequest request) {
|
|
||||||
closeCalled = true;
|
|
||||||
return CloseSessionReply.newBuilder()
|
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class FakeSession implements MxGatewayCli.MxGatewayCliSession {
|
|
||||||
private boolean registerCalled;
|
|
||||||
private boolean addItemCalled;
|
|
||||||
private boolean adviseCalled;
|
|
||||||
private MxValue lastWriteValue;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int register(String clientName) {
|
|
||||||
registerCalled = true;
|
|
||||||
return 42;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply registerRaw(String clientName) {
|
|
||||||
registerCalled = true;
|
|
||||||
return MxCommandReply.newBuilder()
|
|
||||||
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int addItem(int serverHandle, String itemDefinition) {
|
|
||||||
addItemCalled = true;
|
|
||||||
return 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
|
|
||||||
addItemCalled = true;
|
|
||||||
return MxCommandReply.newBuilder()
|
|
||||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.setAddItem(AddItemReply.newBuilder().setItemHandle(7))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void advise(int serverHandle, int itemHandle) {
|
|
||||||
adviseCalled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
|
|
||||||
adviseCalled = true;
|
|
||||||
return MxCommandReply.newBuilder()
|
|
||||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
|
|
||||||
lastWriteValue = value;
|
|
||||||
return MxCommandReply.newBuilder()
|
|
||||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
|
|
||||||
List<SubscribeResult> results = new ArrayList<>();
|
|
||||||
for (int index = 0; index < items.size(); index++) {
|
|
||||||
results.add(SubscribeResult.newBuilder()
|
|
||||||
.setServerHandle(serverHandle)
|
|
||||||
.setTagAddress(items.get(index))
|
|
||||||
.setItemHandle(100 + index)
|
|
||||||
.setWasSuccessful(true)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
|
|
||||||
List<SubscribeResult> results = new ArrayList<>();
|
|
||||||
for (Integer itemHandle : itemHandles) {
|
|
||||||
results.add(SubscribeResult.newBuilder()
|
|
||||||
.setServerHandle(serverHandle)
|
|
||||||
.setItemHandle(itemHandle)
|
|
||||||
.setWasSuccessful(true)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public com.dohertylan.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
|
||||||
throw new UnsupportedOperationException("stream-events is covered by client tests");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ProtocolStatus ok() {
|
|
||||||
return ProtocolStatus.newBuilder()
|
|
||||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
package com.dohertylan.mxgateway.client;
|
|
||||||
|
|
||||||
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.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
|
|
||||||
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
|
|
||||||
* deploy-event stream.
|
|
||||||
*/
|
|
||||||
public final class DeployEventSubscription implements AutoCloseable {
|
|
||||||
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
|
|
||||||
new AtomicReference<>();
|
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
package com.dohertylan.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;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancellable handle returned by {@code queryActiveAlarms}.
|
|
||||||
*
|
|
||||||
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
|
|
||||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
|
||||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
|
||||||
* try-with-resources blocks.
|
|
||||||
*/
|
|
||||||
public final class MxGatewayActiveAlarmsSubscription implements AutoCloseable {
|
|
||||||
private final AtomicReference<ClientCallStreamObserver<QueryActiveAlarmsRequest>> requestStream = new AtomicReference<>();
|
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
package com.dohertylan.mxgateway.client;
|
|
||||||
|
|
||||||
import io.grpc.stub.ClientCallStreamObserver;
|
|
||||||
import io.grpc.stub.ClientResponseObserver;
|
|
||||||
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.StreamEventsRequest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancellable handle returned by the async {@code streamEvents} variant.
|
|
||||||
*
|
|
||||||
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
|
|
||||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
|
||||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
|
||||||
* try-with-resources blocks.
|
|
||||||
*/
|
|
||||||
public final class MxGatewayEventSubscription implements AutoCloseable {
|
|
||||||
private final AtomicReference<ClientCallStreamObserver<StreamEventsRequest>> requestStream = new AtomicReference<>();
|
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-394
@@ -1,394 +0,0 @@
|
|||||||
package com.dohertylan.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.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
import io.grpc.ManagedChannel;
|
|
||||||
import io.grpc.Server;
|
|
||||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
|
||||||
import io.grpc.inprocess.InProcessServerBuilder;
|
|
||||||
import io.grpc.stub.StreamObserver;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.UUID;
|
|
||||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
|
||||||
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.Test;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for the Medium-severity Client.Java code-review findings
|
|
||||||
* (Client.Java-001 through Client.Java-005).
|
|
||||||
*/
|
|
||||||
final class MxGatewayMediumFindingsTests {
|
|
||||||
|
|
||||||
// --- Client.Java-001: redactApiKey must not leak trailing secret chars ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void redactApiKeyDoesNotLeakAnyCharacterOfTheSecret() {
|
|
||||||
// mxgw_<key-id>_<secret> — the secret is the segment after the second underscore.
|
|
||||||
String apiKey = "mxgw_keyid01_supersecretvalue";
|
|
||||||
String redacted = MxGatewaySecrets.redactApiKey(apiKey);
|
|
||||||
|
|
||||||
// None of the secret characters may appear in the redacted output.
|
|
||||||
assertFalse(redacted.contains("value"), () -> "redacted form leaked secret tail: " + redacted);
|
|
||||||
assertFalse(redacted.endsWith("alue"), () -> "redacted form leaked trailing secret chars: " + redacted);
|
|
||||||
assertFalse(redacted.contains("supersecret"), () -> "redacted form leaked secret: " + redacted);
|
|
||||||
// The non-secret key-id prefix may stay so the value is still comparable in logs.
|
|
||||||
assertTrue(redacted.startsWith("mxgw_keyid01_"), () -> "redacted form lost key-id prefix: " + redacted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void redactApiKeyForNonGatewayShapedKeyRevealsNothing() {
|
|
||||||
String redacted = MxGatewaySecrets.redactApiKey("plain-opaque-token-1234");
|
|
||||||
assertFalse(redacted.contains("1234"), () -> "redacted form leaked trailing chars: " + redacted);
|
|
||||||
assertFalse(redacted.contains("plain-opaque-token"), () -> "redacted form leaked body: " + redacted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void redactApiKeyStillHandlesNullAndShortInput() {
|
|
||||||
assertEquals("", MxGatewaySecrets.redactApiKey(null));
|
|
||||||
assertEquals("", MxGatewaySecrets.redactApiKey(""));
|
|
||||||
assertEquals("<redacted>", MxGatewaySecrets.redactApiKey("short"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Client.Java-002: terminal-state transition must be deterministic ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void eventStreamOverflowExceptionSurvivesASubsequentClose() {
|
|
||||||
// Deterministic reproduction of Client.Java-002: an overflow enqueues the
|
|
||||||
// overflow exception, then a later close() must NOT discard it. The first
|
|
||||||
// terminal condition (overflow) must win and stay observable by next().
|
|
||||||
MxEventStream stream = new MxEventStream(2);
|
|
||||||
io.grpc.stub.ClientResponseObserver<
|
|
||||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
|
||||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
|
|
||||||
observer = stream.observer();
|
|
||||||
observer.beforeStart(new NoopRequestStream());
|
|
||||||
|
|
||||||
// Force a queue overflow on a capacity-2 stream.
|
|
||||||
for (int i = 0; i < 8; i++) {
|
|
||||||
observer.onNext(testEvent(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A close() arriving after the overflow must not erase the overflow signal.
|
|
||||||
stream.close();
|
|
||||||
|
|
||||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> {
|
|
||||||
while (stream.hasNext()) {
|
|
||||||
stream.next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
assertTrue(error.getMessage().contains("overflow"), error::getMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void eventStreamConcurrentOverflowAndCloseAlwaysTerminate() throws Exception {
|
|
||||||
// The terminal-state transition must be serialised: whatever the interleaving
|
|
||||||
// of overflow and close, hasNext() always reaches a terminal state.
|
|
||||||
for (int iteration = 0; iteration < 300; iteration++) {
|
|
||||||
MxEventStream stream = new MxEventStream(2);
|
|
||||||
io.grpc.stub.ClientResponseObserver<
|
|
||||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
|
||||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>
|
|
||||||
observer = stream.observer();
|
|
||||||
observer.beforeStart(new NoopRequestStream());
|
|
||||||
|
|
||||||
Thread filler = new Thread(() -> {
|
|
||||||
for (int i = 0; i < 8; i++) {
|
|
||||||
observer.onNext(testEvent(i));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Thread closer = new Thread(stream::close);
|
|
||||||
filler.start();
|
|
||||||
closer.start();
|
|
||||||
filler.join();
|
|
||||||
closer.join();
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (stream.hasNext()) {
|
|
||||||
stream.next();
|
|
||||||
}
|
|
||||||
} catch (MxGatewayException expected) {
|
|
||||||
assertTrue(expected.getMessage().contains("overflow"), expected::getMessage);
|
|
||||||
}
|
|
||||||
assertFalse(stream.hasNext());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class NoopRequestStream
|
|
||||||
extends io.grpc.stub.ClientCallStreamObserver<mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest> {
|
|
||||||
@Override
|
|
||||||
public void cancel(String message, Throwable cause) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isReady() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void request(int count) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setMessageCompression(boolean enable) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void disableAutoInboundFlowControl() {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest value) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable t) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCompleted() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Client.Java-003: gateway protocol version mismatch must be rejected ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void openSessionRejectsIncompatibleGatewayProtocolVersion() throws Exception {
|
|
||||||
TestService service = new TestService() {
|
|
||||||
@Override
|
|
||||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
|
||||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
|
||||||
.setSessionId("session-mismatch")
|
|
||||||
.setGatewayProtocolVersion(MxGatewayClientVersion.gatewayProtocolVersion() + 1)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
|
||||||
MxGatewayException error = assertThrows(
|
|
||||||
MxGatewayException.class,
|
|
||||||
() -> harness.client().openSession("junit-session"));
|
|
||||||
assertTrue(error.getMessage().contains("protocol version"), error::getMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void openSessionAcceptsMatchingOrUnsetGatewayProtocolVersion() throws Exception {
|
|
||||||
TestService matching = new TestService() {
|
|
||||||
@Override
|
|
||||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
|
||||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
|
||||||
.setSessionId("session-ok")
|
|
||||||
.setGatewayProtocolVersion(MxGatewayClientVersion.gatewayProtocolVersion())
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try (Harness harness = Harness.start(matching)) {
|
|
||||||
assertEquals("session-ok", harness.client().openSession("junit-session").sessionId());
|
|
||||||
}
|
|
||||||
|
|
||||||
// A gateway that leaves the field unset (0) must not be rejected — older gateways
|
|
||||||
// simply do not populate it.
|
|
||||||
TestService unset = new TestService();
|
|
||||||
try (Harness harness = Harness.start(unset)) {
|
|
||||||
assertEquals("session-java", harness.client().openSession("junit-session").sessionId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Client.Java-004: missing typed payload AND missing return_value must throw ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void registerThrowsWhenReplyHasNeitherTypedPayloadNorReturnValue() throws Exception {
|
|
||||||
TestService service = new TestService() {
|
|
||||||
@Override
|
|
||||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
|
||||||
// Reply with neither register payload nor return_value set.
|
|
||||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setKind(request.getCommand().getKind())
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
|
||||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
|
||||||
MxGatewayException error = assertThrows(
|
|
||||||
MxGatewayException.class, () -> session.register("c"));
|
|
||||||
assertTrue(error.getMessage().contains("register"), error::getMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void addItemThrowsWhenReplyHasNeitherTypedPayloadNorReturnValue() throws Exception {
|
|
||||||
TestService service = new TestService() {
|
|
||||||
@Override
|
|
||||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
|
||||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setKind(request.getCommand().getKind())
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
|
||||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
|
||||||
assertThrows(MxGatewayException.class, () -> session.addItem(1, "Tag"));
|
|
||||||
assertThrows(MxGatewayException.class, () -> session.addItem2(1, "Tag", "ctx"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void addItemStillHonoursReturnValueFallback() throws Exception {
|
|
||||||
TestService service = new TestService() {
|
|
||||||
@Override
|
|
||||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
|
||||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setKind(request.getCommand().getKind())
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.setReturnValue(mxaccess_gateway.v1.MxaccessGateway.MxValue.newBuilder()
|
|
||||||
.setInt32Value(99))
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
|
||||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
|
||||||
assertEquals(99, session.addItem(1, "Tag"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Client.Java-005: close() must not mask the primary try-with-resources error ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void closeSuppressesCloseTimeFailureInsteadOfMaskingBodyException() throws Exception {
|
|
||||||
TestService service = new TestService() {
|
|
||||||
@Override
|
|
||||||
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
|
|
||||||
responseObserver.onError(io.grpc.Status.UNAVAILABLE
|
|
||||||
.withDescription("WORKER_UNAVAILABLE")
|
|
||||||
.asRuntimeException());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
|
||||||
IllegalStateException bodyError = assertThrows(IllegalStateException.class, () -> {
|
|
||||||
try (MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s")) {
|
|
||||||
throw new IllegalStateException("body failure");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// The body exception must propagate; the close-time RPC failure must not replace it.
|
|
||||||
assertEquals("body failure", bodyError.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void closeRawStillSurfacesCloseTimeFailureForCallersWhoWantIt() throws Exception {
|
|
||||||
TestService service = new TestService() {
|
|
||||||
@Override
|
|
||||||
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
|
|
||||||
responseObserver.onError(io.grpc.Status.UNAVAILABLE
|
|
||||||
.withDescription("WORKER_UNAVAILABLE")
|
|
||||||
.asRuntimeException());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try (Harness harness = Harness.start(service)) {
|
|
||||||
MxGatewaySession session = MxGatewaySession.forSessionId(harness.client(), "s");
|
|
||||||
assertThrows(MxGatewayException.class, session::closeRaw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static mxaccess_gateway.v1.MxaccessGateway.MxEvent testEvent(int sequence) {
|
|
||||||
return mxaccess_gateway.v1.MxaccessGateway.MxEvent.newBuilder()
|
|
||||||
.setWorkerSequence(sequence)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ProtocolStatus ok() {
|
|
||||||
return ProtocolStatus.newBuilder()
|
|
||||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class TestService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
|
||||||
@Override
|
|
||||||
public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
|
|
||||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
|
||||||
.setSessionId("session-java")
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
|
|
||||||
responseObserver.onNext(CloseSessionReply.newBuilder()
|
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
|
||||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
|
||||||
.setSessionId(request.getSessionId())
|
|
||||||
.setKind(MxCommandKind.MX_COMMAND_KIND_UNSPECIFIED)
|
|
||||||
.setProtocolStatus(ok())
|
|
||||||
.build());
|
|
||||||
responseObserver.onCompleted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Harness(Server server, ManagedChannel channel, MxGatewayClient client) implements AutoCloseable {
|
|
||||||
static Harness start(MxAccessGatewayGrpc.MxAccessGatewayImplBase service) throws Exception {
|
|
||||||
String name = "mxgw-medium-" + UUID.randomUUID();
|
|
||||||
Server server = InProcessServerBuilder.forName(name)
|
|
||||||
.directExecutor()
|
|
||||||
.addService(service)
|
|
||||||
.build()
|
|
||||||
.start();
|
|
||||||
ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build();
|
|
||||||
MxGatewayClient client = new MxGatewayClient(
|
|
||||||
channel,
|
|
||||||
MxGatewayClientOptions.builder()
|
|
||||||
.endpoint("in-process")
|
|
||||||
.apiKey("")
|
|
||||||
.plaintext(true)
|
|
||||||
.callTimeout(Duration.ofSeconds(5))
|
|
||||||
.build());
|
|
||||||
return new Harness(server, channel, client);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
channel.shutdownNow();
|
|
||||||
server.shutdownNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
@@ -16,7 +20,7 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = 'mxaccessgw-java'
|
rootProject.name = 'zb-mom-ww-mxaccessgw-java'
|
||||||
|
|
||||||
include 'mxgateway-client'
|
include 'zb-mom-ww-mxgateway-client'
|
||||||
include 'mxgateway-cli'
|
include 'zb-mom-ww-mxgateway-cli'
|
||||||
|
|||||||
+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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+301
@@ -139,6 +139,99 @@ public final class MxAccessGatewayGrpc {
|
|||||||
return getStreamEventsMethod;
|
return getStreamEventsMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "AcknowledgeAlarm",
|
||||||
|
requestType = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.class,
|
||||||
|
responseType = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod() {
|
||||||
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest, mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> getAcknowledgeAlarmMethod;
|
||||||
|
if ((getAcknowledgeAlarmMethod = MxAccessGatewayGrpc.getAcknowledgeAlarmMethod) == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
if ((getAcknowledgeAlarmMethod = MxAccessGatewayGrpc.getAcknowledgeAlarmMethod) == null) {
|
||||||
|
MxAccessGatewayGrpc.getAcknowledgeAlarmMethod = getAcknowledgeAlarmMethod =
|
||||||
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest, mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "AcknowledgeAlarm"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("AcknowledgeAlarm"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getAcknowledgeAlarmMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "StreamAlarms",
|
||||||
|
requestType = mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest.class,
|
||||||
|
responseType = mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod() {
|
||||||
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> getStreamAlarmsMethod;
|
||||||
|
if ((getStreamAlarmsMethod = MxAccessGatewayGrpc.getStreamAlarmsMethod) == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
if ((getStreamAlarmsMethod = MxAccessGatewayGrpc.getStreamAlarmsMethod) == null) {
|
||||||
|
MxAccessGatewayGrpc.getStreamAlarmsMethod = getStreamAlarmsMethod =
|
||||||
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "StreamAlarms"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("StreamAlarms"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getStreamAlarmsMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "QueryActiveAlarms",
|
||||||
|
requestType = mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.class,
|
||||||
|
responseType = mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
|
public static io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod() {
|
||||||
|
io.grpc.MethodDescriptor<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> getQueryActiveAlarmsMethod;
|
||||||
|
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
|
||||||
|
synchronized (MxAccessGatewayGrpc.class) {
|
||||||
|
if ((getQueryActiveAlarmsMethod = MxAccessGatewayGrpc.getQueryActiveAlarmsMethod) == null) {
|
||||||
|
MxAccessGatewayGrpc.getQueryActiveAlarmsMethod = getQueryActiveAlarmsMethod =
|
||||||
|
io.grpc.MethodDescriptor.<mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "QueryActiveAlarms"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new MxAccessGatewayMethodDescriptorSupplier("QueryActiveAlarms"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getQueryActiveAlarmsMethod;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
@@ -232,6 +325,44 @@ public final class MxAccessGatewayGrpc {
|
|||||||
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent> responseObserver) {
|
||||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamEventsMethod(), responseObserver);
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamEventsMethod(), responseObserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
default void acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getAcknowledgeAlarmMethod(), responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
default void streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getStreamAlarmsMethod(), responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Point-in-time snapshot of the currently-active alarm set served from the
|
||||||
|
* gateway's always-on alarm monitor cache (session-less). Used after a
|
||||||
|
* reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
|
* have been missed during a transport blip. Streamed so callers can
|
||||||
|
* 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.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
default void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getQueryActiveAlarmsMethod(), responseObserver);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,6 +429,47 @@ public final class MxAccessGatewayGrpc {
|
|||||||
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||||
getChannel().newCall(getStreamEventsMethod(), getCallOptions()), request, responseObserver);
|
getChannel().newCall(getStreamEventsMethod(), getCallOptions()), request, responseObserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public void acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||||
|
getChannel().newCall(getAcknowledgeAlarmMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public void streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||||
|
getChannel().newCall(getStreamAlarmsMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Point-in-time snapshot of the currently-active alarm set served from the
|
||||||
|
* gateway's always-on alarm monitor cache (session-less). Used after a
|
||||||
|
* reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
|
* have been missed during a transport blip. Streamed so callers can
|
||||||
|
* 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.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||||
|
getChannel().newCall(getQueryActiveAlarmsMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,6 +520,48 @@ public final class MxAccessGatewayGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||||
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) throws io.grpc.StatusException {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
|
||||||
|
getChannel(), getAcknowledgeAlarmMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
|
||||||
|
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>
|
||||||
|
streamAlarms(mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||||
|
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Point-in-time snapshot of the currently-active alarm set served from the
|
||||||
|
* gateway's always-on alarm monitor cache (session-less). Used after a
|
||||||
|
* reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
|
* have been missed during a transport blip. Streamed so callers can
|
||||||
|
* 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.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
|
||||||
|
public io.grpc.stub.BlockingClientCall<?, mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>
|
||||||
|
queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||||
|
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,6 +611,46 @@ public final class MxAccessGatewayGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||||
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
getChannel(), getStreamEventsMethod(), getCallOptions(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply acknowledgeAlarm(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||||
|
getChannel(), getAcknowledgeAlarmMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Session-less central alarm feed. The stream opens with the current
|
||||||
|
* active-alarm snapshot (one `active_alarm` per alarm), then a single
|
||||||
|
* `snapshot_complete`, then a `transition` for every subsequent change.
|
||||||
|
* Served by the gateway's always-on alarm monitor; any number of clients
|
||||||
|
* fan out from the single monitor without opening a worker session.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage> streamAlarms(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||||
|
getChannel(), getStreamAlarmsMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Point-in-time snapshot of the currently-active alarm set served from the
|
||||||
|
* gateway's always-on alarm monitor cache (session-less). Used after a
|
||||||
|
* reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
|
* have been missed during a transport blip. Streamed so callers can
|
||||||
|
* 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.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> queryActiveAlarms(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||||
|
getChannel(), getQueryActiveAlarmsMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -441,12 +695,23 @@ public final class MxAccessGatewayGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
getChannel().newCall(getInvokeMethod(), getCallOptions()), request);
|
getChannel().newCall(getInvokeMethod(), getCallOptions()), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
public com.google.common.util.concurrent.ListenableFuture<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> acknowledgeAlarm(
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
|
getChannel().newCall(getAcknowledgeAlarmMethod(), getCallOptions()), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final int METHODID_OPEN_SESSION = 0;
|
private static final int METHODID_OPEN_SESSION = 0;
|
||||||
private static final int METHODID_CLOSE_SESSION = 1;
|
private static final int METHODID_CLOSE_SESSION = 1;
|
||||||
private static final int METHODID_INVOKE = 2;
|
private static final int METHODID_INVOKE = 2;
|
||||||
private static final int METHODID_STREAM_EVENTS = 3;
|
private static final int METHODID_STREAM_EVENTS = 3;
|
||||||
|
private static final int METHODID_ACKNOWLEDGE_ALARM = 4;
|
||||||
|
private static final int METHODID_STREAM_ALARMS = 5;
|
||||||
|
private static final int METHODID_QUERY_ACTIVE_ALARMS = 6;
|
||||||
|
|
||||||
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>,
|
||||||
@@ -481,6 +746,18 @@ public final class MxAccessGatewayGrpc {
|
|||||||
serviceImpl.streamEvents((mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest) request,
|
serviceImpl.streamEvents((mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest) request,
|
||||||
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent>) responseObserver);
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.MxEvent>) responseObserver);
|
||||||
break;
|
break;
|
||||||
|
case METHODID_ACKNOWLEDGE_ALARM:
|
||||||
|
serviceImpl.acknowledgeAlarm((mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>) responseObserver);
|
||||||
|
break;
|
||||||
|
case METHODID_STREAM_ALARMS:
|
||||||
|
serviceImpl.streamAlarms((mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>) responseObserver);
|
||||||
|
break;
|
||||||
|
case METHODID_QUERY_ACTIVE_ALARMS:
|
||||||
|
serviceImpl.queryActiveAlarms((mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>) responseObserver);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new AssertionError();
|
throw new AssertionError();
|
||||||
}
|
}
|
||||||
@@ -527,6 +804,27 @@ public final class MxAccessGatewayGrpc {
|
|||||||
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest,
|
||||||
mxaccess_gateway.v1.MxaccessGateway.MxEvent>(
|
mxaccess_gateway.v1.MxaccessGateway.MxEvent>(
|
||||||
service, METHODID_STREAM_EVENTS)))
|
service, METHODID_STREAM_EVENTS)))
|
||||||
|
.addMethod(
|
||||||
|
getAcknowledgeAlarmMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply>(
|
||||||
|
service, METHODID_ACKNOWLEDGE_ALARM)))
|
||||||
|
.addMethod(
|
||||||
|
getStreamAlarmsMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage>(
|
||||||
|
service, METHODID_STREAM_ALARMS)))
|
||||||
|
.addMethod(
|
||||||
|
getQueryActiveAlarmsMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest,
|
||||||
|
mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot>(
|
||||||
|
service, METHODID_QUERY_ACTIVE_ALARMS)))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +877,9 @@ public final class MxAccessGatewayGrpc {
|
|||||||
.addMethod(getCloseSessionMethod())
|
.addMethod(getCloseSessionMethod())
|
||||||
.addMethod(getInvokeMethod())
|
.addMethod(getInvokeMethod())
|
||||||
.addMethod(getStreamEventsMethod())
|
.addMethod(getStreamEventsMethod())
|
||||||
|
.addMethod(getAcknowledgeAlarmMethod())
|
||||||
|
.addMethod(getStreamAlarmsMethod())
|
||||||
|
.addMethod(getQueryActiveAlarmsMethod())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3800
-14
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user