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.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Code Review Process
|
||||||
|
|
||||||
|
This document describes how to perform a comprehensive, per-module code review of
|
||||||
|
the `mxaccessgw` codebase and how to track findings to resolution.
|
||||||
|
|
||||||
|
A **module** is one buildable project under `src/` (e.g. `src/ZB.MOM.WW.MxGateway.Worker`)
|
||||||
|
or one language client under `clients/` (e.g. `clients/rust`). Each module has
|
||||||
|
its own folder under `code-reviews/` containing a single `findings.md`.
|
||||||
|
|
||||||
|
## 1. Before you start
|
||||||
|
|
||||||
|
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
||||||
|
- For a `src/` project, `<Module>` is the project name with the `ZB.MOM.WW.MxGateway.`
|
||||||
|
prefix stripped — `src/ZB.MOM.WW.MxGateway.Server` is reviewed in `code-reviews/Server/`.
|
||||||
|
- For a language client, `<Module>` is `Client.<Lang>` — `clients/rust` is
|
||||||
|
reviewed in `code-reviews/Client.Rust/`.
|
||||||
|
2. Identify the design context for the module:
|
||||||
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope,
|
||||||
|
STA thread model, fault handling.
|
||||||
|
- The relevant component design docs under `docs/` (e.g.
|
||||||
|
`docs/MxAccessWorkerInstanceDesign.md`, `docs/GatewayProcessDesign.md`,
|
||||||
|
`docs/Sessions.md`, `docs/Authentication.md`, `docs/GalaxyRepository.md`).
|
||||||
|
- `docs/DesignDecisions.md` for the v1 design choices.
|
||||||
|
- The **Repository-Specific Conventions** and **Process / Platform Notes** in
|
||||||
|
`CLAUDE.md`.
|
||||||
|
3. Record the exact commit being reviewed: `git rev-parse --short HEAD`. Every
|
||||||
|
review is a snapshot — a finding only means something relative to a known
|
||||||
|
commit.
|
||||||
|
4. Open `code-reviews/<Module>/findings.md` and fill in the header table
|
||||||
|
(reviewer, date, commit SHA, status).
|
||||||
|
|
||||||
|
## 2. Review checklist
|
||||||
|
|
||||||
|
Work through **every** category below for the module. A comprehensive review
|
||||||
|
means the checklist is completed even where it produces no findings — record
|
||||||
|
"No issues found" for a category rather than leaving it ambiguous.
|
||||||
|
|
||||||
|
1. **Correctness & logic bugs** — off-by-one, null handling, incorrect
|
||||||
|
conditionals, misuse of APIs, broken edge cases.
|
||||||
|
2. **mxaccessgw conventions** — the rules in `CLAUDE.md` and the style guides
|
||||||
|
under `docs/style-guides/`: the gateway never instantiates MXAccess COM
|
||||||
|
directly; all MXAccess COM calls run on the worker's dedicated STA thread and
|
||||||
|
the STA loop pumps Windows messages; IPC uses one bidirectional named pipe per
|
||||||
|
worker carrying length-prefixed `WorkerEnvelope` protobuf frames; MXAccess
|
||||||
|
parity is the contract (don't "fix" surprising MXAccess behaviour, never
|
||||||
|
synthesize events); one worker and one event subscriber per session; the
|
||||||
|
gateway terminates orphan workers on startup and does not reattach; C# style
|
||||||
|
(file-scoped namespaces, `sealed` by default, `Async` suffix, MXAccess-aligned
|
||||||
|
names); no Blazor UI component libraries; no logging of secrets or full tag
|
||||||
|
values; generated code is never hand-edited.
|
||||||
|
3. **Concurrency & thread safety** — shared mutable state, STA affinity, race
|
||||||
|
conditions, correct use of `async`/`await`, locking, disposal races.
|
||||||
|
4. **Error handling & resilience** — exception paths, worker crash / reconnect
|
||||||
|
handling, fail-fast event backpressure, transient vs permanent error
|
||||||
|
classification, graceful degradation, correct gRPC status codes.
|
||||||
|
5. **Security** — authentication/authorization checks, API-key scope enforcement,
|
||||||
|
input validation, SQL injection in the Galaxy Repository RPCs, secret
|
||||||
|
handling, the dashboard anonymous-localhost bypass, logging of sensitive data.
|
||||||
|
6. **Performance & resource management** — `IDisposable` disposal, pipe / stream
|
||||||
|
/ COM lifetimes, buffering and back-pressure, unnecessary allocations on hot
|
||||||
|
paths, N+1 queries.
|
||||||
|
7. **Design-document adherence** — does the code match `gateway.md`, the relevant
|
||||||
|
`docs/` component designs, `docs/DesignDecisions.md`, and `CLAUDE.md`? Flag
|
||||||
|
both code that drifts from the design and design docs that are now stale.
|
||||||
|
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
||||||
|
Options pattern, separation of concerns, additive-only contract evolution.
|
||||||
|
9. **Testing coverage** — are the module's behaviours covered by tests
|
||||||
|
(`src/ZB.MOM.WW.MxGateway.Tests`, `src/ZB.MOM.WW.MxGateway.Worker.Tests`,
|
||||||
|
`src/ZB.MOM.WW.MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
||||||
|
edge-case tests.
|
||||||
|
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
||||||
|
undocumented non-obvious behaviour.
|
||||||
|
|
||||||
|
## 3. Recording findings
|
||||||
|
|
||||||
|
Add one entry per finding to the `## Findings` section of the module's
|
||||||
|
`findings.md`, using the entry format in
|
||||||
|
[`_template/findings.md`](code-reviews/_template/findings.md).
|
||||||
|
|
||||||
|
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
|
||||||
|
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
|
||||||
|
- **Severity:**
|
||||||
|
- **Critical** — data loss, security breach, crash/deadlock, or outage.
|
||||||
|
- **High** — incorrect behaviour with significant impact; no safe workaround.
|
||||||
|
- **Medium** — incorrect or risky behaviour with limited impact or a workaround.
|
||||||
|
- **Low** — minor issues, style, maintainability, documentation.
|
||||||
|
- **Category** — one of the 10 checklist categories above.
|
||||||
|
- **Location** — `file:line` (clickable), or a list of locations.
|
||||||
|
- **Description** — what is wrong and why it matters.
|
||||||
|
- **Recommendation** — concrete suggested fix.
|
||||||
|
|
||||||
|
After recording findings, update the module header table (status, open-finding
|
||||||
|
count) and regenerate the base README (step 5).
|
||||||
|
|
||||||
|
## 4. Marking an item resolved
|
||||||
|
|
||||||
|
Findings are **never deleted** — they are an audit trail. To close one, change
|
||||||
|
its **Status** and complete the **Resolution** field:
|
||||||
|
|
||||||
|
- `Open` — newly recorded, not yet addressed.
|
||||||
|
- `In Progress` — a fix is actively being worked on.
|
||||||
|
- `Resolved` — fixed. The Resolution field must state the fixing commit SHA, the
|
||||||
|
date, and a one-line description of the fix.
|
||||||
|
- `Won't Fix` — intentionally not fixed. The Resolution field must justify why.
|
||||||
|
- `Deferred` — valid but postponed. The Resolution field must say what it is
|
||||||
|
waiting on (e.g. a tracked issue or a later milestone).
|
||||||
|
|
||||||
|
`Resolved`, `Won't Fix`, and `Deferred` findings are all considered **closed**.
|
||||||
|
`Open` and `In Progress` are **pending** and appear in the base README's Pending
|
||||||
|
Findings table.
|
||||||
|
|
||||||
|
## 5. Updating the base README
|
||||||
|
|
||||||
|
`code-reviews/README.md` holds the single cross-module view (the Module Status
|
||||||
|
table and the Pending / Closed Findings tables). It is **generated** from the
|
||||||
|
per-module `findings.md` files — do not edit it by hand.
|
||||||
|
|
||||||
|
After any review or status change, regenerate it:
|
||||||
|
|
||||||
|
```
|
||||||
|
python code-reviews/regen-readme.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`regen-readme.py --check` exits non-zero if `README.md` is stale, if a module
|
||||||
|
header's `Open findings` count disagrees with its finding statuses, or if a
|
||||||
|
finding carries an unrecognised Status value. The PowerShell wrapper
|
||||||
|
`scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook
|
||||||
|
for CI or a pre-commit step.
|
||||||
|
|
||||||
|
> The repo's installed `python` is the real interpreter; the bare `python3`
|
||||||
|
> alias resolves to the Windows Store stub and fails. Use `python`.
|
||||||
|
|
||||||
|
The per-module `findings.md` files are the source of truth; `README.md` is the
|
||||||
|
aggregated index and must always agree with them — which the script guarantees.
|
||||||
|
|
||||||
|
## 6. Re-reviewing a module
|
||||||
|
|
||||||
|
Re-reviews append to the same `findings.md`. Update the header to the new commit
|
||||||
|
and date, continue the finding numbering from the last used ID, and leave prior
|
||||||
|
findings (including closed ones) in place as history.
|
||||||
+76
-55
@@ -1,42 +1,48 @@
|
|||||||
# Documentation Style Guide
|
# Documentation Style Guide
|
||||||
|
|
||||||
This guide defines writing conventions and formatting rules for all ScadaBridge documentation.
|
This guide defines writing conventions and formatting rules for all MXAccess
|
||||||
|
Gateway (`mxaccessgw`) documentation.
|
||||||
|
|
||||||
## Tone and Voice
|
## Tone and Voice
|
||||||
|
|
||||||
### Be Technical and Direct
|
### Be Technical and Direct
|
||||||
|
|
||||||
Write for developers who are familiar with .NET. Don't explain basic concepts like dependency injection or async/await unless they're used in an unusual way.
|
Write for developers who are familiar with .NET. Don't explain basic concepts
|
||||||
|
like dependency injection or async/await unless they're used in an unusual way.
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
> The `ScadaGatewayActor` routes messages to the appropriate `ScadaClientActor` based on the client ID in the message.
|
> The `SessionManager` launches one worker per session and tracks it through the
|
||||||
|
> session state machine.
|
||||||
|
|
||||||
**Avoid:**
|
**Avoid:**
|
||||||
> The ScadaGatewayActor is a really powerful component that helps manage all your SCADA connections efficiently!
|
> The SessionManager is a really powerful component that helps manage all your
|
||||||
|
> MXAccess connections efficiently!
|
||||||
|
|
||||||
### Explain "Why" Not Just "What"
|
### Explain "Why" Not Just "What"
|
||||||
|
|
||||||
Document the reasoning behind patterns and decisions, not just the mechanics.
|
Document the reasoning behind patterns and decisions, not just the mechanics.
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
> Health checks use a 5-second timeout because actors under heavy load may take several seconds to respond, but longer delays indicate a real problem.
|
> The worker pumps Windows messages on its STA thread because a plain blocking
|
||||||
|
> queue does not let MXAccess COM events deliver.
|
||||||
|
|
||||||
**Avoid:**
|
**Avoid:**
|
||||||
> Health checks use a 5-second timeout.
|
> The worker pumps Windows messages on its STA thread.
|
||||||
|
|
||||||
### Use Present Tense
|
### Use Present Tense
|
||||||
|
|
||||||
Describe what the code does, not what it will do.
|
Describe what the code does, not what it will do.
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
> The actor validates the message before processing.
|
> The gateway terminates orphaned workers on startup.
|
||||||
|
|
||||||
**Avoid:**
|
**Avoid:**
|
||||||
> The actor will validate the message before processing.
|
> The gateway will terminate orphaned workers on startup.
|
||||||
|
|
||||||
### No Marketing Language
|
### No Marketing Language
|
||||||
|
|
||||||
This is internal technical documentation. Avoid superlatives and promotional language.
|
This is internal technical documentation. Avoid superlatives and promotional
|
||||||
|
language.
|
||||||
|
|
||||||
**Avoid:** "powerful", "robust", "cutting-edge", "seamless", "blazing fast"
|
**Avoid:** "powerful", "robust", "cutting-edge", "seamless", "blazing fast"
|
||||||
|
|
||||||
@@ -45,10 +51,10 @@ This is internal technical documentation. Avoid superlatives and promotional lan
|
|||||||
### File Names
|
### File Names
|
||||||
|
|
||||||
Use `PascalCase.md` for all documentation files:
|
Use `PascalCase.md` for all documentation files:
|
||||||
- `Overview.md`
|
- `Sessions.md`
|
||||||
- `HealthChecks.md`
|
- `GatewayConfiguration.md`
|
||||||
- `StateMachines.md`
|
- `WorkerSta.md`
|
||||||
- `SignalR.md`
|
- `Diagnostics.md`
|
||||||
|
|
||||||
### Headings
|
### Headings
|
||||||
|
|
||||||
@@ -58,11 +64,11 @@ Use `PascalCase.md` for all documentation files:
|
|||||||
- **H4+ (`####`):** Rarely needed, Sentence case
|
- **H4+ (`####`):** Rarely needed, Sentence case
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Actor Health Checks
|
# Gateway Configuration
|
||||||
|
|
||||||
## Configuration Options
|
## Session Options
|
||||||
|
|
||||||
### Setting the timeout
|
### Setting the lease timeout
|
||||||
|
|
||||||
#### Default values
|
#### Default values
|
||||||
```
|
```
|
||||||
@@ -73,40 +79,43 @@ Always specify the language:
|
|||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```csharp
|
```csharp
|
||||||
public class MyActor : ReceiveActor { }
|
public sealed class GatewaySession { }
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"Setting": "value"
|
"MxGateway": { "Sessions": { "MaxConcurrent": 8 } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```powershell
|
||||||
dotnet build
|
dotnet build src/ZB.MOM.WW.MxGateway.slnx
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
Supported languages: `csharp`, `json`, `bash`, `xml`, `sql`, `yaml`, `html`, `css`, `javascript`
|
Supported languages: `csharp`, `json`, `bash`, `powershell`, `xml`, `sql`,
|
||||||
|
`text`, `rust`, `python`, `go`, `proto`, `html`, `css`, `toml`.
|
||||||
|
|
||||||
### Code Snippets
|
### Code Snippets
|
||||||
|
|
||||||
**Length:** 5-25 lines is typical. Shorter for simple concepts, longer for complete examples.
|
**Length:** 5-25 lines is typical. Shorter for simple concepts, longer for
|
||||||
|
complete examples.
|
||||||
|
|
||||||
**Context:** Include enough to understand where the code lives:
|
**Context:** Include enough to understand where the code lives:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Good - shows class context
|
// Good - shows class context
|
||||||
public class TemplateInstanceActor : ReceiveActor
|
public sealed class GatewaySession
|
||||||
{
|
{
|
||||||
public TemplateInstanceActor(TemplateInstanceConfig config)
|
public GatewaySession(SessionId sessionId, WorkerPipeSession pipe)
|
||||||
{
|
{
|
||||||
Receive<StartProcessing>(Handle);
|
_sessionId = sessionId;
|
||||||
|
_pipe = pipe;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid - orphaned snippet
|
// Avoid - orphaned snippet
|
||||||
Receive<StartProcessing>(Handle);
|
_pipe = pipe;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Accuracy:** Only use code that exists in the codebase. Never invent examples.
|
**Accuracy:** Only use code that exists in the codebase. Never invent examples.
|
||||||
@@ -134,34 +143,34 @@ Use tables for structured reference information:
|
|||||||
```markdown
|
```markdown
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `Timeout` | `5000` | Milliseconds to wait |
|
| `MaxConcurrent` | `8` | Maximum simultaneous sessions |
|
||||||
| `RetryCount` | `3` | Number of retry attempts |
|
| `LeaseTimeoutSeconds` | `60` | Idle lease before sweep |
|
||||||
```
|
```
|
||||||
|
|
||||||
### Inline Code
|
### Inline Code
|
||||||
|
|
||||||
Use backticks for:
|
Use backticks for:
|
||||||
- Class names: `ScadaGatewayActor`
|
- Class names: `SessionManager`
|
||||||
- Method names: `HandleMessage()`
|
- Method names: `KillWorkerAsync()`
|
||||||
- File names: `appsettings.json`
|
- File names: `appsettings.json`
|
||||||
- Configuration keys: `ScadaBridge:Timeout`
|
- Configuration keys: `MxGateway:Sessions:MaxConcurrent`
|
||||||
- Command-line commands: `dotnet build`
|
- Command-line commands: `dotnet build`
|
||||||
|
|
||||||
### Links
|
### Links
|
||||||
|
|
||||||
Use relative paths for internal documentation:
|
Use relative paths for internal documentation:
|
||||||
```markdown
|
```markdown
|
||||||
[See the Actors guide](../Akka/Actors.md)
|
[See the architecture overview](./gateway.md)
|
||||||
[Configuration options](./Configuration.md)
|
[Configuration options](./docs/GatewayConfiguration.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Use descriptive link text:
|
Use descriptive link text:
|
||||||
```markdown
|
```markdown
|
||||||
<!-- Good -->
|
<!-- Good -->
|
||||||
See the [Actor Health Checks](../Akka/HealthChecks.md) documentation.
|
See the [Gateway Configuration](./docs/GatewayConfiguration.md) documentation.
|
||||||
|
|
||||||
<!-- Avoid -->
|
<!-- Avoid -->
|
||||||
See [here](../Akka/HealthChecks.md) for more.
|
See [here](./docs/GatewayConfiguration.md) for more.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structure Conventions
|
## Structure Conventions
|
||||||
@@ -173,9 +182,10 @@ Every document starts with:
|
|||||||
2. 1-2 sentence description of purpose
|
2. 1-2 sentence description of purpose
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Actor Health Checks
|
# Worker STA Thread
|
||||||
|
|
||||||
Health checks monitor actor responsiveness and report status to the ASP.NET Core health check system.
|
The worker owns one MXAccess COM instance on a dedicated STA thread and pumps
|
||||||
|
Windows messages so MXAccess events deliver.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Section Organization
|
### Section Organization
|
||||||
@@ -194,15 +204,15 @@ Organize content from general to specific:
|
|||||||
Place code examples immediately after the concept they illustrate:
|
Place code examples immediately after the concept they illustrate:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Message Handling
|
## Session Close
|
||||||
|
|
||||||
Actors process messages using `Receive<T>` handlers:
|
The gateway closes a session by killing its worker behind the close gate:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
Receive<MyMessage>(msg => HandleMyMessage(msg));
|
await session.KillWorkerWithCloseGateAsync(cancellationToken);
|
||||||
```
|
```
|
||||||
|
|
||||||
Each handler processes one message type...
|
The close gate serializes concurrent close attempts...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Related Documentation Section
|
### Related Documentation Section
|
||||||
@@ -212,9 +222,9 @@ End each document with links to related topics:
|
|||||||
```markdown
|
```markdown
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Actor Patterns](./Patterns.md)
|
- [Sessions](./docs/Sessions.md)
|
||||||
- [Health Checks](../Operations/HealthChecks.md)
|
- [Worker STA Thread](./docs/WorkerSta.md)
|
||||||
- [Configuration](../Configuration/Akka.md)
|
- [Gateway Configuration](./docs/GatewayConfiguration.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
@@ -222,30 +232,33 @@ End each document with links to related topics:
|
|||||||
### Match Code Exactly
|
### Match Code Exactly
|
||||||
|
|
||||||
Use the exact names from source code:
|
Use the exact names from source code:
|
||||||
- `TemplateInstanceActor` not "Template Instance Actor"
|
- `MxStatusProxy` not "MX status proxy"
|
||||||
- `ScadaGatewayActor` not "SCADA Gateway Actor"
|
- `SessionManager` not "session manager"
|
||||||
- `IRequiredActor<T>` not "required actor interface"
|
- `OrphanWorkerTerminator` not "orphan worker terminator"
|
||||||
|
|
||||||
### Acronyms
|
### Acronyms
|
||||||
|
|
||||||
Spell out on first use, then use acronym:
|
Spell out on first use, then use acronym:
|
||||||
> OPC Unified Architecture (OPC UA) provides industrial communication standards. OPC UA servers expose...
|
> Single-threaded apartment (STA) threads serialize COM calls. STA message
|
||||||
|
> pumping lets MXAccess events deliver...
|
||||||
|
|
||||||
Common acronyms that don't need expansion:
|
Common acronyms that don't need expansion:
|
||||||
- API
|
- API
|
||||||
- JSON
|
- JSON
|
||||||
- SQL
|
- SQL
|
||||||
- HTTP/HTTPS
|
- HTTP/HTTPS
|
||||||
- REST
|
- COM
|
||||||
- JWT
|
- gRPC
|
||||||
|
- IPC
|
||||||
|
- STA
|
||||||
- UI
|
- UI
|
||||||
|
|
||||||
### File Paths
|
### File Paths
|
||||||
|
|
||||||
Use forward slashes and backticks:
|
Use forward slashes and backticks:
|
||||||
- `src/Infrastructure/Akka/Actors/`
|
- `src/ZB.MOM.WW.MxGateway.Server/`
|
||||||
- `appsettings.json`
|
- `appsettings.json`
|
||||||
- `Documentation/Akka/Overview.md`
|
- `docs/GatewayConfiguration.md`
|
||||||
|
|
||||||
## What to Avoid
|
## What to Avoid
|
||||||
|
|
||||||
@@ -260,13 +273,14 @@ The constructor creates a new instance of the class.
|
|||||||
<!-- Better - only document if there's something notable -->
|
<!-- Better - only document if there's something notable -->
|
||||||
## Constructor
|
## Constructor
|
||||||
|
|
||||||
The constructor accepts an `IActorRef` for the gateway actor, which must be resolved before actor creation.
|
The constructor accepts a `WorkerPipeSession`, which must be connected before
|
||||||
|
the session transitions out of `Handshaking`.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Don't Duplicate Source Code Comments
|
### Don't Duplicate Source Code Comments
|
||||||
|
|
||||||
If code has good comments, reference the file rather than copying:
|
If code has good comments, reference the file rather than copying:
|
||||||
> See `ScadaGatewayActor.cs` lines 45-60 for the message routing logic.
|
> See `SessionManager.cs` for the open-failure rollback order.
|
||||||
|
|
||||||
### Don't Include Temporary Information
|
### Don't Include Temporary Information
|
||||||
|
|
||||||
@@ -278,5 +292,12 @@ Assume readers know:
|
|||||||
- Dependency injection
|
- Dependency injection
|
||||||
- async/await
|
- async/await
|
||||||
- LINQ
|
- LINQ
|
||||||
- Entity Framework basics
|
|
||||||
- ASP.NET Core middleware pipeline
|
- ASP.NET Core middleware pipeline
|
||||||
|
- gRPC service basics
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Architecture overview](./gateway.md)
|
||||||
|
- [Gateway Configuration](./docs/GatewayConfiguration.md)
|
||||||
|
- [C# Style Guide](./docs/style-guides/CSharpStyleGuide.md)
|
||||||
|
- [Go Style Guide](./docs/style-guides/GoStyleGuide.md), [Java Style Guide](./docs/style-guides/JavaStyleGuide.md), [Python Style Guide](./docs/style-guides/PythonStyleGuide.md), [Rust Style Guide](./docs/style-guides/RustStyleGuide.md), [Protobuf Style Guide](./docs/style-guides/ProtobufStyleGuide.md)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
|
||||||
|
<Authors>Joseph Doherty</Authors>
|
||||||
|
<Company>ZB MOM WW</Company>
|
||||||
|
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||||
|
<Product>MxAccessGateway Client</Product>
|
||||||
|
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
||||||
|
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
|
||||||
|
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||||
|
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- Default: do NOT pack. Each project opts in. -->
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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 @@
|
|||||||
|
|
||||||
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,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")]
|
|
||||||
+120
-27
@@ -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.
|
||||||
|
|
||||||
@@ -117,15 +126,17 @@ reply.
|
|||||||
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,
|
||||||
@@ -180,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
|
||||||
@@ -217,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:
|
||||||
@@ -237,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>
|
||||||
+40
-3
@@ -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.
|
||||||
@@ -190,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)
|
||||||
@@ -204,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 },
|
||||||
@@ -214,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)
|
||||||
@@ -229,14 +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>
|
||||||
|
/// Records the stream-alarms call and yields each enqueued feed message.
|
||||||
|
/// </summary>
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
StreamAlarmsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
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
-2
@@ -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
|
||||||
@@ -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;
|
||||||
+19
-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>
|
/// <summary>
|
||||||
/// gRPC implementation of IGalaxyRepositoryClientTransport.
|
/// gRPC implementation of IGalaxyRepositoryClientTransport.
|
||||||
@@ -74,6 +74,23 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.BrowseChildrenAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
+44
-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;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// gRPC implementation of IMxGatewayClientTransport.
|
/// gRPC implementation of IMxGatewayClientTransport.
|
||||||
@@ -175,6 +175,48 @@ 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(
|
private static Exception MapRpcException(
|
||||||
RpcException exception,
|
RpcException exception,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
+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
|
||||||
+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 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
|
||||||
+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 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
|
||||||
+37
-3
@@ -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.
|
||||||
@@ -224,6 +224,28 @@ 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>
|
||||||
@@ -293,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()
|
||||||
{
|
{
|
||||||
@@ -313,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;
|
||||||
@@ -328,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;
|
||||||
+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
|
||||||
+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>
|
/// <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.
|
||||||
+167
-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>
|
/// <summary>
|
||||||
/// Represents one gateway-backed MXAccess session.
|
/// Represents one gateway-backed MXAccess session.
|
||||||
@@ -502,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>
|
||||||
+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:
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
|
||||||
|
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
|
||||||
|
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
|
||||||
|
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
|
||||||
|
true` to verify against the OS/system trust roots without pinning. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||||
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||||
@@ -84,6 +92,13 @@ goroutine cleanup. Raw protobuf messages remain available through the
|
|||||||
`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
|
||||||
@@ -114,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
|
||||||
@@ -206,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])
|
||||||
@@ -337,11 +357,378 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
|
handles, err := parseInt32List(*itemHandles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), parseInt32List(*itemHandles))
|
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
|
||||||
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
results, err := session.ReadBulk(ctx, int32(*serverHandle), parseStringList(*items), time.Duration(*timeoutMs)*time.Millisecond)
|
||||||
|
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 {
|
||||||
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
@@ -428,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)
|
||||||
@@ -514,7 +1014,7 @@ func parseStringList(value string) []string {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseInt32List(value string) []int32 {
|
func parseInt32List(value string) ([]int32, error) {
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
items := make([]int32, 0, len(parts))
|
items := make([]int32, 0, len(parts))
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
@@ -524,11 +1024,11 @@ func parseInt32List(value string) []int32 {
|
|||||||
}
|
}
|
||||||
parsed, err := strconv.ParseInt(item, 10, 32)
|
parsed, err := strconv.ParseInt(item, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return nil, fmt.Errorf("invalid item handle %q: %w", item, err)
|
||||||
}
|
}
|
||||||
items = append(items, int32(parsed))
|
items = append(items, int32(parsed))
|
||||||
}
|
}
|
||||||
return items
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
||||||
@@ -647,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("", " ")
|
||||||
@@ -666,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 {
|
||||||
@@ -56,3 +90,207 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
|
|||||||
t.Fatalf("int32 value = %d, want 123", got)
|
t.Fatalf("int32 value = %d, want 123", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
|
||||||
|
// secured-only flags must be unavailable on non-secured variants, and
|
||||||
|
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
|
||||||
|
// 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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("runWithIO(%v) returned no error", tc.args)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
|
||||||
|
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
|
||||||
|
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
server := grpc.NewServer()
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|||||||
@@ -230,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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
-27
@@ -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,6 +57,16 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
|
||||||
|
no `caCertificatePath` accepts whatever certificate the gateway presents (via
|
||||||
|
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
|
||||||
|
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
|
||||||
|
verify against the JVM trust store without pinning. Use `serverNameOverride` /
|
||||||
|
`--server-name-override` when the dialed host differs from the certificate SAN.
|
||||||
|
See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
||||||
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||||
underlying protobuf messages. `MxGatewayCommandException` and
|
underlying protobuf messages. `MxGatewayCommandException` and
|
||||||
@@ -67,6 +78,12 @@ 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
|
||||||
@@ -104,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
|
||||||
@@ -156,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
|
||||||
@@ -165,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`,
|
||||||
@@ -182,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
|
||||||
@@ -202,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
|
||||||
|
|
||||||
@@ -217,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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-309
@@ -1,309 +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\""));
|
|
||||||
assertTrue(run.output().contains("mxgw***********cret"));
|
|
||||||
assertFalse(run.output().contains("visible_secret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3862
-76
File diff suppressed because it is too large
Load Diff
+34100
-426
File diff suppressed because it is too large
Load Diff
@@ -12608,8 +12608,8 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
|||||||
"CONVERSION_FAILED\020\010\022\"\n\036WORKER_FAULT_CATE" +
|
"CONVERSION_FAILED\020\010\022\"\n\036WORKER_FAULT_CATE" +
|
||||||
"GORY_STA_HUNG\020\t\022(\n$WORKER_FAULT_CATEGORY" +
|
"GORY_STA_HUNG\020\t\022(\n$WORKER_FAULT_CATEGORY" +
|
||||||
"_QUEUE_OVERFLOW\020\n\022*\n&WORKER_FAULT_CATEGO" +
|
"_QUEUE_OVERFLOW\020\n\022*\n&WORKER_FAULT_CATEGO" +
|
||||||
"RY_SHUTDOWN_TIMEOUT\020\013B\034\252\002\031MxGateway.Cont" +
|
"RY_SHUTDOWN_TIMEOUT\020\013B&\252\002#ZB.MOM.WW.MxGa" +
|
||||||
"racts.Protob\006proto3"
|
"teway.Contracts.Protob\006proto3"
|
||||||
};
|
};
|
||||||
descriptor = com.google.protobuf.Descriptors.FileDescriptor
|
descriptor = com.google.protobuf.Descriptors.FileDescriptor
|
||||||
.internalBuildGeneratedFileFrom(descriptorData,
|
.internalBuildGeneratedFileFrom(descriptorData,
|
||||||
|
|||||||
+2
-2
@@ -3,11 +3,11 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':mxgateway-client')
|
implementation project(':zb-mom-ww-mxgateway-client')
|
||||||
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
|
||||||
implementation "info.picocli:picocli:${picocliVersion}"
|
implementation "info.picocli:picocli:${picocliVersion}"
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
mainClass = 'com.dohertylan.mxgateway.cli.MxGatewayCli'
|
mainClass = 'com.zb.mom.ww.mxgateway.cli.MxGatewayCli'
|
||||||
}
|
}
|
||||||
+1820
File diff suppressed because it is too large
Load Diff
+753
@@ -0,0 +1,753 @@
|
|||||||
|
package com.zb.mom.ww.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 com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||||
|
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.OnAlarmTransitionEvent;
|
||||||
|
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.StreamAlarmsRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
|
||||||
|
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\""));
|
||||||
|
assertTrue(run.output().contains("mxgw***********cret"));
|
||||||
|
assertFalse(run.output().contains("visible_secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stream-alarms / acknowledge-alarm subcommands ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandForwardsFilterPrefixAndPrintsFeedMessages() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Tank01");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("Tank01", factory.client.lastStreamAlarmsRequest.getAlarmFilterPrefix());
|
||||||
|
String out = run.output();
|
||||||
|
assertTrue(out.contains("active-alarm Tank01.Level.HiHi"), out);
|
||||||
|
assertTrue(out.contains("snapshot-complete"), out);
|
||||||
|
assertTrue(out.contains("transition Tank01.Level.HiHi"), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandHonoursLimit() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--limit", "1");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
long lines = run.output().lines().filter(line -> !line.isBlank()).count();
|
||||||
|
assertEquals(1, lines, "expected exactly one feed message with --limit 1, got: " + run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandPrintsJson() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertTrue(run.output().contains("\"activeAlarm\""), run.output());
|
||||||
|
assertTrue(run.output().contains("\"snapshotComplete\""), run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acknowledgeAlarmCommandForwardsOptionsAndPrintsReply() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
CliRun run = execute(
|
||||||
|
factory,
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--reference",
|
||||||
|
"Tank01.Level.HiHi",
|
||||||
|
"--comment",
|
||||||
|
"checked",
|
||||||
|
"--operator",
|
||||||
|
"operator1",
|
||||||
|
"--json");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||||
|
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||||
|
assertTrue(run.output().contains("\"command\":\"acknowledge-alarm\""), run.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acknowledgeAlarmCommandRequiresReference() {
|
||||||
|
CliRun run = execute(new FakeClientFactory(), "acknowledge-alarm", "--comment", "checked");
|
||||||
|
|
||||||
|
assertFalse(run.exitCode() == 0, "expected non-zero exit without --reference");
|
||||||
|
assertTrue(run.errors().contains("--reference"), run.errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readmeDocumentedStreamAlarmsExampleParsesCleanly() {
|
||||||
|
// Client.Java-032 regression — the README's stream-alarms example
|
||||||
|
// (clients/java/README.md:182) must round-trip through picocli's
|
||||||
|
// parser without a parse error. Before the fix, the example used
|
||||||
|
// a non-existent --session-id option and picocli failed at parse
|
||||||
|
// time. This test pins the exact tokens documented in the README.
|
||||||
|
String[] args = {
|
||||||
|
"stream-alarms",
|
||||||
|
"--endpoint",
|
||||||
|
"localhost:5000",
|
||||||
|
"--api-key-env",
|
||||||
|
"MXGATEWAY_API_KEY",
|
||||||
|
"--plaintext",
|
||||||
|
"--filter-prefix",
|
||||||
|
"Galaxy",
|
||||||
|
"--limit",
|
||||||
|
"1",
|
||||||
|
"--json"
|
||||||
|
};
|
||||||
|
assertReadmeExampleParses(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readmeDocumentedAcknowledgeAlarmExampleParsesCleanly() {
|
||||||
|
// Client.Java-032 regression — the README's acknowledge-alarm
|
||||||
|
// example (clients/java/README.md:183) must parse without error.
|
||||||
|
// Before the fix it used --session-id (no such option) and
|
||||||
|
// --alarm-reference (the real option is --reference), so picocli
|
||||||
|
// rejected the invocation immediately.
|
||||||
|
String[] args = {
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--endpoint",
|
||||||
|
"localhost:5000",
|
||||||
|
"--api-key-env",
|
||||||
|
"MXGATEWAY_API_KEY",
|
||||||
|
"--plaintext",
|
||||||
|
"--reference",
|
||||||
|
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||||
|
"--json"
|
||||||
|
};
|
||||||
|
assertReadmeExampleParses(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the given args through the production picocli {@link CommandLine}
|
||||||
|
* and asserts no parser error, no unknown option, and no missing required
|
||||||
|
* option. Does not execute the command body — only the option / subcommand
|
||||||
|
* parser is exercised, so no network call is made.
|
||||||
|
*/
|
||||||
|
private static void assertReadmeExampleParses(String[] args) {
|
||||||
|
picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
|
||||||
|
try {
|
||||||
|
commandLine.parseArgs(args);
|
||||||
|
} catch (picocli.CommandLine.ParameterException ex) {
|
||||||
|
throw new AssertionError(
|
||||||
|
"documented README invocation failed picocli parse: "
|
||||||
|
+ String.join(" ", args)
|
||||||
|
+ " -> "
|
||||||
|
+ ex.getMessage(),
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandFailsFastOnQueueOverflow() {
|
||||||
|
// Client.Java-033 regression — the CLI's stream-alarms bounded queue
|
||||||
|
// used queue.offer(value) which silently dropped messages past
|
||||||
|
// capacity (1024). After the fix the CLI must surface the overflow
|
||||||
|
// as a non-zero exit (mirroring MxEventStream's fail-fast contract).
|
||||||
|
//
|
||||||
|
// The OverflowingFakeClient floods the gRPC observer with 2000
|
||||||
|
// messages synchronously, which exceeds the bounded 1024-element
|
||||||
|
// queue. The fix detects the failed offer, cancels the subscription,
|
||||||
|
// queues an overflow exception, and the drain loop surfaces it.
|
||||||
|
OverflowingFakeClientFactory factory = new OverflowingFakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Flood");
|
||||||
|
|
||||||
|
assertFalse(run.exitCode() == 0,
|
||||||
|
"expected non-zero exit when the alarm queue overflows; got exit=" + run.exitCode()
|
||||||
|
+ " out=\n" + run.output() + "\nerr=\n" + run.errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandExecutesVersionAndEmitsEorMarker() {
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
String out = run.output();
|
||||||
|
assertTrue(out.contains("\"clientVersion\""), out);
|
||||||
|
assertTrue(out.contains(MxGatewayCli.BATCH_EOR), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandTokenisesDoubleQuotedArgumentWithEmbeddedSpaces() {
|
||||||
|
// Client.Java-034 regression — a real shell-style tokenizer must not
|
||||||
|
// shred `"needs verification"` into two arguments. Drives
|
||||||
|
// acknowledge-alarm through batch and asserts the captured --comment
|
||||||
|
// is the un-quoted string with the embedded space preserved.
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"needs verification\" --operator op1\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
assertEquals("op1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||||
|
assertEquals(
|
||||||
|
"Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandTokenisesSingleQuotedArgumentWithEmbeddedSpaces() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line =
|
||||||
|
"acknowledge-alarm --reference Tank01.Level.HiHi --comment 'needs verification' --operator op1\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandTokenisesBackslashEscapedSpaceOutsideQuotes() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line =
|
||||||
|
"acknowledge-alarm --reference Tank01.Level.HiHi --comment needs\\ verification\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandPreservesEmptyQuotedArgument() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"\"\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandSupportsBackslashEscapedQuoteInsideDoubleQuotes() {
|
||||||
|
// `--comment "with \"inner\" quote"` should round-trip the inner
|
||||||
|
// double-quote into the comment string.
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line =
|
||||||
|
"acknowledge-alarm --reference Tank01.Level.HiHi --comment \"with \\\"inner\\\" quote\"\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("with \"inner\" quote", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandEmitsEorAfterFailedCommandAndContinues() {
|
||||||
|
// An unknown subcommand causes a picocli parse error (non-zero exit).
|
||||||
|
// The loop must still emit BATCH_EOR for the failure and continue
|
||||||
|
// processing the subsequent valid command.
|
||||||
|
CliRun run = executeBatch(new FakeClientFactory(), "no-such-subcommand\nversion --json\n");
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
String out = run.output();
|
||||||
|
long eorCount = out.lines()
|
||||||
|
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
|
||||||
|
.count();
|
||||||
|
assertEquals(2, eorCount, "expected exactly 2 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
|
||||||
|
assertTrue(out.contains("\"clientVersion\""), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the CLI with {@code batch} as the subcommand, using the provided
|
||||||
|
* string as standard input content. Temporarily replaces {@link System#in}
|
||||||
|
* for the duration of the call.
|
||||||
|
*/
|
||||||
|
private static CliRun executeBatch(MxGatewayCli.MxGatewayCliClientFactory factory, String stdinContent) {
|
||||||
|
InputStream originalIn = System.in;
|
||||||
|
try {
|
||||||
|
System.setIn(new ByteArrayInputStream(stdinContent.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
return execute(factory, "batch");
|
||||||
|
} finally {
|
||||||
|
System.setIn(originalIn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory whose fake client floods the {@code streamAlarms} observer with
|
||||||
|
* 2000 messages synchronously, exceeding the CLI's bounded 1024-element
|
||||||
|
* queue. Used by the Client.Java-033 fail-fast overflow regression.
|
||||||
|
*/
|
||||||
|
private static final class OverflowingFakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
||||||
|
return new OverflowingFakeClient(options.spec.commandLine().getOut());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class OverflowingFakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||||
|
private final PrintWriter out;
|
||||||
|
|
||||||
|
OverflowingFakeClient(PrintWriter out) {
|
||||||
|
this.out = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrintWriter out() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpenSessionReply openSession(OpenSessionRequest request) {
|
||||||
|
return OpenSessionReply.newBuilder().setSessionId("flood-session").setProtocolStatus(ok()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CloseSessionReply closeSession(CloseSessionRequest request) {
|
||||||
|
return CloseSessionReply.newBuilder()
|
||||||
|
.setSessionId(request.getSessionId())
|
||||||
|
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||||
|
// Synchronously push 2000 messages to overflow the CLI's bounded
|
||||||
|
// 1024-element queue. The CLI must surface the overflow rather
|
||||||
|
// than silently dropping the trailing ~976 messages.
|
||||||
|
for (int i = 0; i < 2000; i++) {
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||||
|
.setAlarmFullReference("Flood." + i)
|
||||||
|
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
observer.onCompleted();
|
||||||
|
return new MxGatewayAlarmFeedSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||||
|
private final PrintWriter out;
|
||||||
|
private final FakeSession session = new FakeSession();
|
||||||
|
private boolean closeCalled;
|
||||||
|
private AcknowledgeAlarmRequest lastAcknowledgeAlarmRequest;
|
||||||
|
private StreamAlarmsRequest lastStreamAlarmsRequest;
|
||||||
|
|
||||||
|
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 AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||||
|
lastAcknowledgeAlarmRequest = request;
|
||||||
|
return AcknowledgeAlarmReply.newBuilder()
|
||||||
|
.setCorrelationId(request.getClientCorrelationId())
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.setHresult(0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||||
|
lastStreamAlarmsRequest = request;
|
||||||
|
// Replay a deterministic active-alarm snapshot, snapshot-complete
|
||||||
|
// sentinel, transition, then complete the feed so the CLI command
|
||||||
|
// drains a bounded stream without contacting a live gateway.
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setTransition(OnAlarmTransitionEvent.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
observer.onCompleted();
|
||||||
|
return new MxGatewayAlarmFeedSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
|
||||||
|
List<BulkReadResult> results = new ArrayList<>();
|
||||||
|
for (int index = 0; index < items.size(); index++) {
|
||||||
|
results.add(BulkReadResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setTagAddress(items.get(index))
|
||||||
|
.setItemHandle(200 + index)
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.setWasCached(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (WriteBulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (Write2BulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (WriteSecuredBulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||||
|
List<BulkWriteResult> results = new ArrayList<>();
|
||||||
|
for (WriteSecured2BulkEntry entry : entries) {
|
||||||
|
results.add(BulkWriteResult.newBuilder()
|
||||||
|
.setServerHandle(serverHandle)
|
||||||
|
.setItemHandle(entry.getItemHandle())
|
||||||
|
.setWasSuccessful(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public com.zb.mom.ww.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java-library'
|
id 'java-library'
|
||||||
id 'com.google.protobuf'
|
id 'com.google.protobuf'
|
||||||
|
id 'maven-publish'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -22,7 +23,7 @@ dependencies {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
main {
|
main {
|
||||||
proto {
|
proto {
|
||||||
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
|
srcDir rootProject.file('../../src/ZB.MOM.WW.MxGateway.Contracts/Protos')
|
||||||
include 'mxaccess_gateway.proto'
|
include 'mxaccess_gateway.proto'
|
||||||
include 'mxaccess_worker.proto'
|
include 'mxaccess_worker.proto'
|
||||||
include 'galaxy_repository.proto'
|
include 'galaxy_repository.proto'
|
||||||
@@ -30,6 +31,11 @@ sourceSets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
withSourcesJar()
|
||||||
|
withJavadocJar()
|
||||||
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
protoc {
|
protoc {
|
||||||
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user