Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7f754c77b | |||
| 144c293f05 | |||
| 7c957908f8 | |||
| 6659653673 | |||
| 75a39f5a8c | |||
| cebe67e9bd | |||
| ddf2d84fbc | |||
| 56dd56954b | |||
| b57d02cc4d | |||
| 47062c1a6e | |||
| d0d1dcef15 | |||
| fb2b1a4a52 | |||
| d2c776901b | |||
| 258e09e0de | |||
| 410acc92eb | |||
| b40aaeef05 | |||
| 9208225f9c | |||
| c6f17557f6 | |||
| bbbef4d098 | |||
| 4af24b9518 | |||
| 371ce53409 | |||
| 597677025f | |||
| 393e326275 | |||
| 986dcee14a | |||
| a3752799de | |||
| 37aadf72b3 | |||
| 5573f2a229 | |||
| 56abd64c6c | |||
| 5b31e99ab6 | |||
| 64db828d71 | |||
| 1a9367b5de | |||
| 98e997b573 | |||
| 0e8d911fd8 | |||
| e72763d703 | |||
| 3c9becc8d6 | |||
| ec88532fe4 | |||
| 2f30f0c7c0 | |||
| 27f6c9e6b7 | |||
| 29bd504a99 | |||
| e10b252e3a | |||
| bcc54ca56b | |||
| ee459f43e1 | |||
| ebf1d95f72 | |||
| 3ccf0b5f9e | |||
| f7ccfd678e | |||
| 3f5e5fc0b3 | |||
| 7241a4fb9c | |||
| d6c0bb41ca | |||
| 0a54c0bc4b | |||
| fd64b9260c | |||
| 4bd757a136 | |||
| 1e2ed6d1ea | |||
| 5f6655de27 | |||
| fbc9cf56df | |||
| 4c0e14fc5d | |||
| c75920c620 | |||
| a46ce90e6f | |||
| f113ca53a1 | |||
| f3616cc7fa | |||
| 57d5a8725f | |||
| 60d35a914f | |||
| b10e103bcf | |||
| 348ab16456 | |||
| c16f016f0a | |||
| 1d85db7b4e | |||
| 5ea5618315 | |||
| 38a0ad8ab4 | |||
| 5df2ef0d1e | |||
| e5785fd769 | |||
| 22370ca4da | |||
| e0a3fbf35b | |||
| 161ed6f80d | |||
| 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 |
@@ -45,6 +45,7 @@ build/
|
||||
out/
|
||||
tmp/
|
||||
temp/
|
||||
install/
|
||||
|
||||
# .NET
|
||||
**/bin/
|
||||
@@ -146,3 +147,8 @@ generated-scratch/
|
||||
|
||||
# Keep empty directories with .gitkeep files when needed
|
||||
!.gitkeep
|
||||
|
||||
# Documentation review artifacts (CommentChecker output)
|
||||
*-docs-issues.md
|
||||
*-docs-fixed.md
|
||||
*-docs-final.md
|
||||
|
||||
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
||||
## Design Sources To Consult Before Non-Trivial Changes
|
||||
|
||||
- `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` — shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) 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/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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Build-quality enforcement floor, mirroring src/Directory.Build.props so the
|
||||
.NET client tree is held to the same baseline CLAUDE.md mandates (warnings as
|
||||
errors, code-style enforced at build, latest analyzers, deterministic builds). -->
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<Deterministic>true</Deterministic>
|
||||
</PropertyGroup>
|
||||
|
||||
<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>
|
||||
<!-- Proprietary/internal package, consistent with the Rust ("Proprietary") and
|
||||
Python ("Proprietary") client license declarations. A LicenseRef SPDX expression
|
||||
is rejected by the current NuGet toolset (NU5124), so the proprietary terms ship
|
||||
as a packaged license file instead. -->
|
||||
<PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
|
||||
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
|
||||
<Version>0.1.1</Version>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<!-- Default: do NOT pack. Each project opts in. -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -107,6 +107,7 @@ public sealed class MxGatewayClientOptions
|
||||
public required string ApiKey { get; init; }
|
||||
public bool UseTls { get; init; }
|
||||
public string? CaCertificatePath { get; init; }
|
||||
public bool RequireCertificateValidation { get; init; }
|
||||
public string? ServerNameOverride { get; init; }
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
@@ -124,6 +125,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
|
||||
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
|
||||
|
||||
Use a gRPC call credentials/interceptor layer to attach:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Proprietary License
|
||||
|
||||
Copyright (c) ZB MOM WW. All rights reserved.
|
||||
|
||||
This software and its source code are proprietary and confidential. They are
|
||||
licensed, not sold, for internal use within ZB MOM WW and its authorized
|
||||
partners only. No part of this package may be reproduced, distributed, or
|
||||
transmitted to third parties without the prior written permission of ZB MOM WW.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
||||
@@ -84,6 +84,15 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
||||
available, and command helpers have `*RawAsync` variants when callers need the
|
||||
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
|
||||
the first `CloseSessionReply` instead of sending another close request.
|
||||
|
||||
@@ -125,6 +134,8 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --s
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||
```
|
||||
|
||||
@@ -185,6 +196,54 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-las
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
||||
@@ -228,6 +287,17 @@ Use TLS options for a secured gateway:
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||
@@ -240,6 +310,29 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
```
|
||||
|
||||
## 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.1
|
||||
````
|
||||
|
||||
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -45,6 +45,27 @@ public interface IMxGatewayCliClient : IAsyncDisposable
|
||||
StreamEventsRequest request,
|
||||
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>
|
||||
/// Tests connection to the Galaxy Repository.
|
||||
/// </summary>
|
||||
|
||||
@@ -52,6 +52,22 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
||||
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 />
|
||||
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
||||
TestConnectionRequest request,
|
||||
|
||||
@@ -16,6 +16,8 @@ public static class MxGatewayClientCli
|
||||
|
||||
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>
|
||||
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
||||
/// <param name="standardOutput">TextWriter for command output.</param>
|
||||
@@ -25,7 +27,7 @@ public static class MxGatewayClientCli
|
||||
TextWriter standardOutput,
|
||||
TextWriter standardError)
|
||||
{
|
||||
return RunAsync(args, standardOutput, standardError)
|
||||
return RunAsync(args, standardOutput, standardError, clientFactory: null, standardInput: null)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
@@ -35,11 +37,13 @@ public static class MxGatewayClientCli
|
||||
/// <param name="standardOutput">TextWriter for command output.</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="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
||||
public static Task<int> RunAsync(
|
||||
string[] args,
|
||||
TextWriter standardOutput,
|
||||
TextWriter standardError,
|
||||
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null)
|
||||
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null,
|
||||
TextReader? standardInput = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
ArgumentNullException.ThrowIfNull(standardOutput);
|
||||
@@ -49,14 +53,17 @@ public static class MxGatewayClientCli
|
||||
args,
|
||||
standardOutput,
|
||||
standardError,
|
||||
clientFactory ?? CreateDefaultClient);
|
||||
clientFactory ?? CreateDefaultClient,
|
||||
standardInput ?? Console.In);
|
||||
}
|
||||
|
||||
private static async Task<int> RunCoreAsync(
|
||||
string[] args,
|
||||
TextWriter standardOutput,
|
||||
TextWriter standardError,
|
||||
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory)
|
||||
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory,
|
||||
TextReader standardInput,
|
||||
bool forceJsonErrors = false)
|
||||
{
|
||||
if (args.Length is 0 || IsHelp(args[0]))
|
||||
{
|
||||
@@ -65,6 +72,12 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
string command = args[0].ToLowerInvariant();
|
||||
|
||||
if (command is "batch")
|
||||
{
|
||||
return await RunBatchAsync(standardOutput, clientFactory, standardInput).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
CliArguments arguments = new(args.Skip(1));
|
||||
|
||||
try
|
||||
@@ -101,8 +114,24 @@ public static class MxGatewayClientCli
|
||||
.ConfigureAwait(false),
|
||||
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.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)
|
||||
.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)
|
||||
.ConfigureAwait(false),
|
||||
"write2" => await Write2Async(arguments, client, standardOutput, cancellation.Token)
|
||||
@@ -125,7 +154,7 @@ public static class MxGatewayClientCli
|
||||
string? apiKey = arguments.GetOptional("api-key");
|
||||
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
||||
|
||||
if (arguments.HasFlag("json"))
|
||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||
{
|
||||
standardError.WriteLine(JsonSerializer.Serialize(
|
||||
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)
|
||||
{
|
||||
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
|
||||
@@ -369,6 +478,499 @@ public static class MxGatewayClientCli
|
||||
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(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
@@ -447,29 +1049,37 @@ public static class MxGatewayClientCli
|
||||
AfterWorkerSequence = arguments.GetUInt64("after-worker-sequence", 0),
|
||||
};
|
||||
|
||||
await foreach (MxEvent gatewayEvent in client.StreamEventsAsync(request, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
try
|
||||
{
|
||||
if (jsonLines)
|
||||
await foreach (MxEvent gatewayEvent in client.StreamEventsAsync(request, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||
}
|
||||
else if (json)
|
||||
{
|
||||
events.Add(gatewayEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||
}
|
||||
if (jsonLines)
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||
}
|
||||
else if (json)
|
||||
{
|
||||
events.Add(gatewayEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||
}
|
||||
|
||||
eventCount++;
|
||||
if (maxEvents > 0 && eventCount >= maxEvents)
|
||||
{
|
||||
break;
|
||||
eventCount++;
|
||||
if (maxEvents > 0 && eventCount >= maxEvents)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -481,6 +1091,124 @@ public static class MxGatewayClientCli
|
||||
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(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
@@ -755,11 +1483,15 @@ public static class MxGatewayClientCli
|
||||
|
||||
private static MxValue ParseValue(CliArguments arguments)
|
||||
{
|
||||
string type = arguments.GetRequired("type").ToLowerInvariant();
|
||||
string value = arguments.GetRequired("value");
|
||||
return ParseValue(arguments.GetRequired("type"), arguments.GetRequired("value"));
|
||||
}
|
||||
|
||||
private static MxValue ParseValue(string type, string value)
|
||||
{
|
||||
string normalisedType = type.ToLowerInvariant();
|
||||
string[] values = value.Split(',', StringSplitOptions.TrimEntries);
|
||||
|
||||
return type switch
|
||||
return normalisedType switch
|
||||
{
|
||||
"bool" or "boolean" => bool.Parse(value).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))
|
||||
.ToArray()
|
||||
.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 "subscribe-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-alarms"
|
||||
or "acknowledge-alarm"
|
||||
or "write"
|
||||
or "write2"
|
||||
or "smoke"
|
||||
@@ -1032,6 +1772,7 @@ public static class MxGatewayClientCli
|
||||
|
||||
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 ping --session-id <id> [--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 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 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-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 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]");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
|
||||
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
|
||||
/// </summary>
|
||||
public sealed class BrowseChildrenSmokeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that BrowseChildren returns a non-zero cache sequence and
|
||||
/// a consistent children/child-has-children count from a live gateway.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
|
||||
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
|
||||
{
|
||||
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
||||
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
|
||||
|
||||
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
|
||||
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
|
||||
|
||||
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
|
||||
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
|
||||
|
||||
Assert.True(reply.CacheSequence > 0UL);
|
||||
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
@@ -122,6 +123,49 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
: 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>
|
||||
/// Optional hook awaited inside BrowseChildren before the reply is produced. Lets a
|
||||
/// test hold an RPC mid-flight to exercise concurrent reads of the in-progress node.
|
||||
/// </summary>
|
||||
public Func<Task>? BrowseChildrenGate { get; set; }
|
||||
|
||||
/// <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 async Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||
BrowseChildrenRequest request,
|
||||
CallOptions callOptions)
|
||||
{
|
||||
BrowseChildrenCalls.Add((request, callOptions));
|
||||
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
if (BrowseChildrenGate is { } gate)
|
||||
{
|
||||
await gate().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
|
||||
? reply
|
||||
: BrowseChildrenReply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
||||
/// </summary>
|
||||
|
||||
@@ -51,6 +51,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
|
||||
/// </summary>
|
||||
@@ -58,6 +63,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
|
||||
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
|
||||
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
|
||||
private readonly List<AlarmFeedMessage> _alarmFeedMessages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the reply to return from OpenSessionAsync.
|
||||
@@ -190,6 +196,8 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
/// <summary>
|
||||
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge alarm request.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -213,6 +221,8 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
/// <summary>
|
||||
/// Records the query call and yields each enqueued snapshot.
|
||||
/// </summary>
|
||||
/// <param name="request">The query active alarms request.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -228,14 +238,42 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
}
|
||||
|
||||
/// <summary>Enqueues an acknowledge reply.</summary>
|
||||
/// <param name="reply">The acknowledge reply to enqueue.</param>
|
||||
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
|
||||
{
|
||||
_acknowledgeReplies.Enqueue(reply);
|
||||
}
|
||||
|
||||
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
|
||||
/// <param name="snapshot">The snapshot to enqueue.</param>
|
||||
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,9 @@ public sealed class GalaxyRepositoryClientTests
|
||||
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||
{
|
||||
@@ -212,6 +215,9 @@ public sealed class GalaxyRepositoryClientTests
|
||||
Assert.True(request.HistorizedOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
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 reading Children/IsExpanded concurrently with an in-flight ExpandAsync
|
||||
/// never throws (no torn enumeration of a mid-append list) and, once IsExpanded flips to
|
||||
/// true, the published Children snapshot is fully populated. Pins the safe-publication
|
||||
/// contract on the lock-free readers (Client.Dotnet-025).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Expand_ConcurrentReadOfChildren_NeverTearsAndPublishesAtomically()
|
||||
{
|
||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||
children: [BuildObject(1, "Plant", isArea: true)],
|
||||
childHasChildren: [true],
|
||||
cacheSequence: 1));
|
||||
|
||||
// Multi-page child set so the expand loop spends meaningful time appending,
|
||||
// widening the window for a concurrent reader to observe a torn list.
|
||||
BrowseChildrenReply childPage1 = BuildReply(
|
||||
children: [BuildObject(10, "A"), BuildObject(11, "B"), BuildObject(12, "C")],
|
||||
childHasChildren: [false, false, false],
|
||||
cacheSequence: 1);
|
||||
childPage1.NextPageToken = "1:p:3";
|
||||
transport.BrowseChildrenReplies.Enqueue(childPage1);
|
||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||
children: [BuildObject(13, "D"), BuildObject(14, "E")],
|
||||
childHasChildren: [false, false],
|
||||
cacheSequence: 1));
|
||||
|
||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||
LazyBrowseNode node = roots[0];
|
||||
|
||||
// Gate the child-page RPCs so the expand stays mid-flight while the reader spins.
|
||||
using SemaphoreSlim release = new(0, 1);
|
||||
bool firstChildCall = true;
|
||||
transport.BrowseChildrenGate = async () =>
|
||||
{
|
||||
if (firstChildCall)
|
||||
{
|
||||
firstChildCall = false;
|
||||
await release.WaitAsync().ConfigureAwait(false);
|
||||
}
|
||||
};
|
||||
|
||||
using CancellationTokenSource readerStop = new();
|
||||
Exception? readerFailure = null;
|
||||
Task reader = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!readerStop.IsCancellationRequested)
|
||||
{
|
||||
bool expanded = node.IsExpanded;
|
||||
|
||||
// Enumerate the snapshot; a torn/mid-append list would throw here.
|
||||
int count = 0;
|
||||
foreach (LazyBrowseNode _ in node.Children)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
// If the node reports expanded, the published snapshot must be complete.
|
||||
if (expanded)
|
||||
{
|
||||
Assert.Equal(5, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
readerFailure = ex;
|
||||
}
|
||||
});
|
||||
|
||||
Task expand = node.ExpandAsync();
|
||||
// Let the reader spin against the empty pre-publication snapshot for a moment.
|
||||
await Task.Delay(50);
|
||||
release.Release();
|
||||
await expand;
|
||||
|
||||
// Let the reader observe the post-publication state, then stop it.
|
||||
await Task.Delay(50);
|
||||
readerStop.Cancel();
|
||||
await reader;
|
||||
|
||||
Assert.Null(readerFailure);
|
||||
Assert.True(node.IsExpanded);
|
||||
Assert.Equal(5, node.Children.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_WithFilter_ForwardsToRequest()
|
||||
{
|
||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||
|
||||
await client.BrowseAsync(new BrowseChildrenOptions
|
||||
{
|
||||
TagNameGlob = "Mixer*",
|
||||
AlarmBearingOnly = true,
|
||||
});
|
||||
|
||||
BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
|
||||
Assert.Equal("Mixer*", request.TagNameGlob);
|
||||
Assert.True(request.AlarmBearingOnly);
|
||||
}
|
||||
|
||||
private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
|
||||
=> new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
|
||||
|
||||
private static BrowseChildrenReply BuildReply(
|
||||
IReadOnlyList<GalaxyObject> children,
|
||||
IReadOnlyList<bool> childHasChildren,
|
||||
ulong cacheSequence)
|
||||
{
|
||||
BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
|
||||
reply.Children.AddRange(children);
|
||||
reply.ChildHasChildren.AddRange(childHasChildren);
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
|
||||
=> new(transport.Options, transport);
|
||||
|
||||
private static FakeGalaxyRepositoryTransport CreateTransport()
|
||||
=> new(new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "test-api-key",
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClientAlarmsTests
|
||||
{
|
||||
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
||||
{
|
||||
@@ -46,6 +47,7 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||
{
|
||||
@@ -69,6 +71,7 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
cancellation.Token));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||
{
|
||||
@@ -93,6 +96,7 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
||||
{
|
||||
@@ -117,6 +121,7 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Single(transport.QueryActiveAlarmsCalls);
|
||||
}
|
||||
|
||||
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
||||
{
|
||||
@@ -136,6 +141,7 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
||||
}
|
||||
|
||||
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||
{
|
||||
|
||||
@@ -148,6 +148,87 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||
{
|
||||
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
|
||||
});
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"stream-alarms",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--filter-prefix",
|
||||
"Tank01",
|
||||
"--max-events",
|
||||
"1",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
|
||||
Assert.Equal("Tank01", request.AlarmFilterPrefix);
|
||||
string text = output.ToString();
|
||||
Assert.Contains("active-alarm", text);
|
||||
Assert.Contains("Tank01.Level.HiHi", text);
|
||||
Assert.DoesNotContain("snapshot-complete", text);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
||||
{
|
||||
CorrelationId = "ack-fixture",
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Hresult = 0,
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"acknowledge-alarm",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
"--comment",
|
||||
"ack from cli",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
|
||||
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||
Assert.Equal("ack from cli", request.Comment);
|
||||
Assert.Equal("operator1", request.OperatorUser);
|
||||
Assert.Contains("ack-fixture", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||
@@ -368,6 +449,416 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Contains("\"objectCount\": 99", text);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
using var input = new StringReader("version --json\n");
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
["batch"],
|
||||
output,
|
||||
error,
|
||||
clientFactory: null,
|
||||
standardInput: input);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string text = output.ToString();
|
||||
Assert.Contains("\"gatewayProtocolVersion\":3", text);
|
||||
Assert.Contains("__MXGW_BATCH_EOR__", text);
|
||||
// The EOR marker must come after the JSON output.
|
||||
int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
|
||||
int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||
Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
// Unknown command should produce an error on the captured error stream,
|
||||
// which batch mode re-emits to stdout inside the same delimited block.
|
||||
using var input = new StringReader("nope-not-a-command\nversion\n");
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
["batch"],
|
||||
output,
|
||||
error,
|
||||
clientFactory: null,
|
||||
standardInput: input);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string text = output.ToString();
|
||||
// Two records → two EOR markers.
|
||||
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
||||
int secondEor = text.IndexOf(
|
||||
"__MXGW_BATCH_EOR__",
|
||||
firstEor + 1,
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(firstEor > 0);
|
||||
Assert.True(secondEor > firstEor);
|
||||
// The unknown-command error message must be on stdout (not on stderr).
|
||||
Assert.Contains("nope-not-a-command", text);
|
||||
Assert.DoesNotContain("nope-not-a-command", error.ToString());
|
||||
// The follow-up `version` line should still succeed.
|
||||
Assert.Contains("gateway-protocol=", text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client.Dotnet-018: the README CLI examples for the alarm subcommands at
|
||||
/// `clients/dotnet/README.md` must drive cleanly through the production
|
||||
/// CLI argument parser. The previous text used non-existent flags
|
||||
/// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
|
||||
/// fail with "Unknown command" / "Missing required option --reference".
|
||||
/// Each documented example is extracted from the README, parsed via the
|
||||
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
|
||||
/// against exit code 0.
|
||||
/// </summary>
|
||||
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
||||
[Theory]
|
||||
[InlineData("stream-alarms")]
|
||||
[InlineData("acknowledge-alarm")]
|
||||
public async Task RunAsync_ReadmeExamples_ForAlarmCommands_ParseSuccessfully(string command)
|
||||
{
|
||||
string readme = LocateClientReadme();
|
||||
string[] commandLine = ExtractReadmeCommandLine(readme, command);
|
||||
// The documented examples do not include --api-key (the README assumes
|
||||
// the env var path documented elsewhere). Inject an API key via the
|
||||
// standard env var so CreateOptions succeeds and the parser fully
|
||||
// exercises the documented flag shape.
|
||||
string? previousKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
||||
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", "test-api-key");
|
||||
|
||||
try
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||
{
|
||||
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "fixture" },
|
||||
});
|
||||
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
||||
{
|
||||
CorrelationId = "ack-fixture",
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
commandLine,
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.True(
|
||||
exitCode == 0,
|
||||
$"README example for '{command}' exited {exitCode}; stderr=<<{error}>>");
|
||||
Assert.DoesNotContain("Unknown command", error.ToString());
|
||||
Assert.DoesNotContain("Missing required option", error.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", previousKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client.Dotnet-019: `BenchReadBulkAsync` previously fell back to
|
||||
/// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
|
||||
/// typed <c>Register</c> payload, silently driving the rest of the bench
|
||||
/// against a zero server handle. The fix must fail loudly with a
|
||||
/// descriptive <see cref="MxGatewayException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
// Successful protocol + MX status but no typed `Register` payload.
|
||||
// Before the Client.Dotnet-019 fix this silently became serverHandle=0
|
||||
// and the bench proceeded through SubscribeBulk / warmup / steady-state
|
||||
// against an invalid handle, producing a misleading zero-result summary.
|
||||
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Register,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"bench-read-bulk",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--duration-seconds",
|
||||
"1",
|
||||
"--warmup-seconds",
|
||||
"0",
|
||||
"--bulk-size",
|
||||
"1",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
// Descriptive message that names the missing typed payload.
|
||||
string err = error.ToString();
|
||||
Assert.Contains("Register", err);
|
||||
// The bench must not produce any aggregate stats JSON.
|
||||
Assert.DoesNotContain("bench-read-bulk", output.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client.Dotnet-020: the steady-state loop in `BenchReadBulkAsync` had a
|
||||
/// bare `catch { failedCalls++; continue; }` that swallowed
|
||||
/// <see cref="OperationCanceledException"/>, so token-driven cancellation
|
||||
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
||||
/// the bench must exit promptly when the supplied token cancels.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int invokeCount = 0;
|
||||
FakeCliClient fakeClient = new()
|
||||
{
|
||||
InvokeHandler = (request, ct) =>
|
||||
{
|
||||
int n = Interlocked.Increment(ref invokeCount);
|
||||
|
||||
// Reply 1 = Register (success with typed payload).
|
||||
if (request.Command.Kind == MxCommandKind.Register)
|
||||
{
|
||||
return Task.FromResult(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Register,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Register = new RegisterReply { ServerHandle = 1 },
|
||||
});
|
||||
}
|
||||
|
||||
// Reply 2 = SubscribeBulk (success).
|
||||
if (request.Command.Kind == MxCommandKind.SubscribeBulk)
|
||||
{
|
||||
var subscribeReply = new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.SubscribeBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
SubscribeBulk = new BulkSubscribeReply(),
|
||||
};
|
||||
return Task.FromResult(subscribeReply);
|
||||
}
|
||||
|
||||
// ReadBulk reply 1 = success (so the steady-state loop enters
|
||||
// and starts iterating). Reply 2+ = simulated cancellation.
|
||||
if (request.Command.Kind == MxCommandKind.ReadBulk && n <= 3)
|
||||
{
|
||||
return Task.FromResult(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
ReadBulk = new BulkReadReply(),
|
||||
});
|
||||
}
|
||||
|
||||
// From here on every ReadBulk throws OCE — the steady-state
|
||||
// loop must exit promptly rather than spinning until
|
||||
// --duration-seconds elapses.
|
||||
throw new OperationCanceledException();
|
||||
},
|
||||
};
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||
await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"bench-read-bulk",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--duration-seconds",
|
||||
"30",
|
||||
"--warmup-seconds",
|
||||
"0",
|
||||
"--bulk-size",
|
||||
"1",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient));
|
||||
sw.Stop();
|
||||
|
||||
// Without the fix the loop swallows OCE and continues until the 30 s
|
||||
// steady-state deadline expires. With the fix it exits as soon as OCE
|
||||
// surfaces. Generous 10 s ceiling to keep the test stable under load.
|
||||
Assert.True(
|
||||
sw.Elapsed < TimeSpan.FromSeconds(10),
|
||||
$"Bench did not exit promptly on cancellation; took {sw.Elapsed}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client.Dotnet-021: both `ReadBulkAsync` and `BenchReadBulkAsync` cast
|
||||
/// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
|
||||
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
|
||||
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
||||
/// </summary>
|
||||
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
||||
[Theory]
|
||||
[InlineData("read-bulk")]
|
||||
[InlineData("bench-read-bulk")]
|
||||
public async Task RunAsync_TimeoutMs_NegativeValue_RejectsWithClearError(string command)
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
|
||||
string[] args = command is "read-bulk"
|
||||
? [
|
||||
"read-bulk",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--session-id",
|
||||
"session-fixture",
|
||||
"--server-handle",
|
||||
"1",
|
||||
"--items",
|
||||
"Area001.Pump001.Speed",
|
||||
"--timeout-ms",
|
||||
"-1",
|
||||
]
|
||||
: [
|
||||
"bench-read-bulk",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--duration-seconds",
|
||||
"1",
|
||||
"--warmup-seconds",
|
||||
"0",
|
||||
"--bulk-size",
|
||||
"1",
|
||||
"--timeout-ms",
|
||||
"-1",
|
||||
];
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
args,
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.NotEqual(0, exitCode);
|
||||
string err = error.ToString();
|
||||
Assert.Contains("timeout-ms", err);
|
||||
Assert.Contains("non-negative", err);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates the .NET client README by walking up from the test assembly's
|
||||
/// base directory until <c>clients/dotnet/README.md</c> is found. Keeps
|
||||
/// the regression test independent of the current working directory.
|
||||
/// </summary>
|
||||
private static string LocateClientReadme()
|
||||
{
|
||||
string? directory = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
string candidate = Path.Combine(directory, "clients", "dotnet", "README.md");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("clients/dotnet/README.md not found above test assembly base directory.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the documented CLI invocation for the requested subcommand
|
||||
/// from the README, returning only the arguments after the
|
||||
/// <c>mxgw-dotnet</c>-equivalent prefix so they can be passed straight
|
||||
/// to <see cref="MxGatewayClientCli.RunAsync"/>.
|
||||
/// </summary>
|
||||
private static string[] ExtractReadmeCommandLine(string readmePath, string command)
|
||||
{
|
||||
string[] lines = File.ReadAllLines(readmePath);
|
||||
// Look for the documented `dotnet run ... -- <command> ...` line.
|
||||
foreach (string line in lines)
|
||||
{
|
||||
int dashes = line.IndexOf("-- " + command, StringComparison.Ordinal);
|
||||
if (dashes < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string after = line[(dashes + 3)..].Trim();
|
||||
// Tokenize by whitespace, respecting "..." quoted segments.
|
||||
return TokenizeCommandLine(after);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"README at '{readmePath}' has no documented example for subcommand '{command}'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a single command-line string into argv tokens, honouring
|
||||
/// double-quoted segments so paths with embedded spaces survive intact.
|
||||
/// </summary>
|
||||
private static string[] TokenizeCommandLine(string input)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
bool inQuotes = false;
|
||||
|
||||
foreach (char ch in input)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuotes && char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
tokens.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current.Append(ch);
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
tokens.Add(current.ToString());
|
||||
}
|
||||
|
||||
return tokens.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Fake CLI client for testing.</summary>
|
||||
private sealed class FakeCliClient : IMxGatewayCliClient
|
||||
{
|
||||
@@ -386,6 +877,9 @@ public sealed class MxGatewayClientCliTests
|
||||
/// <summary>Exception to throw on invoke, if any.</summary>
|
||||
public Exception? InvokeFailure { get; init; }
|
||||
|
||||
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
||||
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -431,6 +925,11 @@ public sealed class MxGatewayClientCliTests
|
||||
throw InvokeFailure;
|
||||
}
|
||||
|
||||
if (InvokeHandler is not null)
|
||||
{
|
||||
return InvokeHandler(request, cancellationToken);
|
||||
}
|
||||
|
||||
return Task.FromResult(InvokeReplies.Dequeue());
|
||||
}
|
||||
|
||||
@@ -447,6 +946,41 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
||||
public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();
|
||||
|
||||
/// <summary>List of received acknowledge-alarm requests.</summary>
|
||||
public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];
|
||||
|
||||
/// <summary>List of received stream-alarms requests.</summary>
|
||||
public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];
|
||||
|
||||
/// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
|
||||
public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AcknowledgeAlarmRequests.Add(request);
|
||||
return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
StreamAlarmsRequests.Add(request);
|
||||
foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Yield();
|
||||
yield return feedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Galaxy test connection reply to return.</summary>
|
||||
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
||||
|
||||
@@ -456,6 +990,7 @@ public sealed class MxGatewayClientCliTests
|
||||
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
||||
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
||||
|
||||
/// <summary>Queue of galaxy discover hierarchy replies to return.</summary>
|
||||
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
||||
|
||||
/// <summary>List of received galaxy test connection requests.</summary>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxGatewayClientTlsHandlerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
||||
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
||||
/// The callback must return true regardless of chain errors.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
||||
{
|
||||
MxGatewayClientOptions options = new()
|
||||
{
|
||||
Endpoint = new Uri("https://localhost:5120"),
|
||||
ApiKey = "k",
|
||||
UseTls = true,
|
||||
};
|
||||
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
||||
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when RequireCertificateValidation is true, the callback is left null
|
||||
/// so the OS trust store performs validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
||||
{
|
||||
MxGatewayClientOptions options = new()
|
||||
{
|
||||
Endpoint = new Uri("https://localhost:5120"),
|
||||
ApiKey = "k",
|
||||
UseTls = true,
|
||||
RequireCertificateValidation = true,
|
||||
};
|
||||
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
||||
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GalaxyRepositoryClientTlsHandlerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
||||
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
||||
/// The callback must return true regardless of chain errors.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
||||
{
|
||||
MxGatewayClientOptions options = new()
|
||||
{
|
||||
Endpoint = new Uri("https://localhost:5120"),
|
||||
ApiKey = "k",
|
||||
UseTls = true,
|
||||
};
|
||||
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
||||
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
|
||||
/// so the OS trust store performs validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
||||
{
|
||||
MxGatewayClientOptions options = new()
|
||||
{
|
||||
Endpoint = new Uri("https://localhost:5120"),
|
||||
ApiKey = "k",
|
||||
UseTls = true,
|
||||
RequireCertificateValidation = true,
|
||||
};
|
||||
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
||||
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync(BrowseChildrenOptions, System.Threading.CancellationToken)"/>.
|
||||
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
|
||||
/// </summary>
|
||||
public sealed class BrowseChildrenOptions
|
||||
{
|
||||
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
|
||||
public IReadOnlyList<int> CategoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
|
||||
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
|
||||
|
||||
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
|
||||
public string? TagNameGlob { get; init; }
|
||||
|
||||
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
|
||||
public bool? IncludeAttributes { get; init; }
|
||||
|
||||
/// <summary>Restrict to children that bear at least one alarm attribute.</summary>
|
||||
public bool AlarmBearingOnly { get; init; }
|
||||
|
||||
/// <summary>Restrict to children that have at least one historized attribute.</summary>
|
||||
public bool HistorizedOnly { get; init; }
|
||||
}
|
||||
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
||||
public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
{
|
||||
private const int DiscoverHierarchyPageSize = 5000;
|
||||
private const int BrowseChildrenPageSize = 500;
|
||||
|
||||
private readonly GrpcChannel? _channel;
|
||||
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||
@@ -182,6 +183,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
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(
|
||||
DiscoverHierarchyOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -274,6 +279,89 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
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>
|
||||
/// 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
|
||||
@@ -402,7 +490,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
.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()
|
||||
{
|
||||
@@ -422,6 +513,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||
{
|
||||
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
@@ -437,6 +533,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
return customChain.Build(certificateToValidate);
|
||||
};
|
||||
}
|
||||
else if (!options.RequireCertificateValidation)
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
|
||||
@@ -74,6 +74,23 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||
BrowseChildrenRequest request,
|
||||
CallOptions callOptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await RawClient.BrowseChildrenAsync(request, callOptions)
|
||||
.ResponseAsync
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||
WatchDeployEventsRequest request,
|
||||
|
||||
@@ -175,6 +175,48 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
return QueryActiveAlarmsAsync(request, callOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
||||
? cancellationToken
|
||||
: callOptions.CancellationToken;
|
||||
|
||||
using AsyncServerStreamingCall<AlarmFeedMessage> call = RawClient.StreamAlarms(request, callOptions);
|
||||
|
||||
IAsyncStreamReader<AlarmFeedMessage> responseStream = call.ResponseStream;
|
||||
while (true)
|
||||
{
|
||||
AlarmFeedMessage? message;
|
||||
try
|
||||
{
|
||||
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
message = responseStream.Current;
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
throw MapRpcException(exception, effectiveCancellationToken);
|
||||
}
|
||||
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
IAsyncEnumerable<AlarmFeedMessage> IMxGatewayClientTransport.StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions)
|
||||
{
|
||||
return StreamAlarmsAsync(request, callOptions);
|
||||
}
|
||||
|
||||
private static Exception MapRpcException(
|
||||
RpcException exception,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -33,6 +33,13 @@ internal interface IGalaxyRepositoryClientTransport
|
||||
DiscoverHierarchyRequest request,
|
||||
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>
|
||||
/// <param name="request">The watch deploy events request.</param>
|
||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||
|
||||
@@ -75,4 +75,15 @@ internal interface IMxGatewayClientTransport
|
||||
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
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,120 @@
|
||||
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 SemaphoreSlim _expandLock = new(1, 1);
|
||||
|
||||
// Published once, under _expandLock, when expansion completes. Lock-free readers
|
||||
// see either the empty pre-expansion snapshot or the fully-populated post-expansion
|
||||
// snapshot — never a partially-filled list — because the snapshot is built in a local
|
||||
// and handed off via Volatile.Write (release) paired with Volatile.Read (acquire).
|
||||
private IReadOnlyList<LazyBrowseNode> _children = [];
|
||||
private volatile 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 => Volatile.Read(ref _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. <see cref="Children"/> and
|
||||
/// <see cref="IsExpanded"/> may be read concurrently with an in-flight
|
||||
/// <see cref="ExpandAsync"/> on another thread; the populated children are
|
||||
/// published as an immutable snapshot under a release barrier, so a reader that
|
||||
/// observes <see cref="IsExpanded"/> as <see langword="true"/> always sees the
|
||||
/// fully-populated <see cref="Children"/>, and a reader never enumerates a
|
||||
/// partially-built list.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
// Accumulate into a local list, never the published field, so a lock-free
|
||||
// reader can never observe a half-populated collection or enumerate a list
|
||||
// that is being mutated mid-append.
|
||||
List<LazyBrowseNode> children = [];
|
||||
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));
|
||||
|
||||
// Publish the completed, immutable snapshot (release) before marking the node
|
||||
// expanded (the volatile write below). A reader that observes IsExpanded == true
|
||||
// is guaranteed to also observe the fully-populated Children.
|
||||
Volatile.Write(ref _children, children);
|
||||
_isExpanded = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_expandLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,28 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
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>
|
||||
/// Disposes the client and releases all resources.
|
||||
/// </summary>
|
||||
@@ -293,7 +315,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
.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()
|
||||
{
|
||||
@@ -313,6 +338,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||
{
|
||||
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
@@ -328,6 +358,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
return customChain.Build(certificateToValidate);
|
||||
};
|
||||
}
|
||||
else if (!options.RequireCertificateValidation)
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
|
||||
@@ -7,9 +7,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
||||
/// </summary>
|
||||
public static class MxGatewayClientContractInfo
|
||||
{
|
||||
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
||||
public const uint GatewayProtocolVersion =
|
||||
GatewayContractInfo.GatewayProtocolVersion;
|
||||
|
||||
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
|
||||
public const uint WorkerProtocolVersion =
|
||||
GatewayContractInfo.WorkerProtocolVersion;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets the server name override for SNI during TLS handshake.
|
||||
/// </summary>
|
||||
@@ -47,6 +55,9 @@ public sealed class MxGatewayClientOptions
|
||||
/// </summary>
|
||||
public TimeSpan? StreamTimeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum size in bytes for gRPC messages.
|
||||
/// </summary>
|
||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -502,6 +502,171 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
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>
|
||||
/// Writes a value to an item on the MXAccess server.
|
||||
/// </summary>
|
||||
|
||||
@@ -16,4 +16,26 @@
|
||||
<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>
|
||||
<!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and
|
||||
Tests projects are not packable and do not document their public surface, so this
|
||||
stays out of the shared Directory.Build.props to avoid CS1591 on test classes. -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="..\LICENSE.txt" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -104,6 +104,23 @@ Support:
|
||||
- `credentials.NewClientTLSFromFile`,
|
||||
- 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
|
||||
|
||||
`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`,
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||
`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 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
|
||||
|
||||
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
|
||||
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/galaxy_repository/v1"
|
||||
|
||||
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
|
||||
|
||||
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
||||
@@ -206,6 +283,41 @@ $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
|
||||
```
|
||||
|
||||
## 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.1
|
||||
````
|
||||
|
||||
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 set `GOPRIVATE=gitea.dohertylan.com/*` to fetch the module
|
||||
straight from the VCS — this both bypasses the public module proxy and
|
||||
disables checksum-database (`sum.golang.org`) verification for that path.
|
||||
Add `GOINSECURE=gitea.dohertylan.com/*` if the host serves the module over
|
||||
plain HTTP rather than HTTPS.
|
||||
|
||||
## 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
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -89,10 +91,26 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||
case "unsubscribe-bulk":
|
||||
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":
|
||||
return runWrite(ctx, args[1:], stdout, stderr)
|
||||
case "stream-events":
|
||||
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":
|
||||
return runSmoke(ctx, args[1:], stdout, stderr)
|
||||
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)
|
||||
case "galaxy-watch":
|
||||
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
|
||||
case "batch":
|
||||
return runBatch(ctx, os.Stdin, stdout, stderr)
|
||||
default:
|
||||
writeUsage(stderr)
|
||||
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()
|
||||
|
||||
handles, err := parseInt32List(*itemHandles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -428,6 +815,119 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
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 {
|
||||
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -514,7 +1014,7 @@ func parseStringList(value string) []string {
|
||||
return items
|
||||
}
|
||||
|
||||
func parseInt32List(value string) []int32 {
|
||||
func parseInt32List(value string) ([]int32, error) {
|
||||
parts := strings.Split(value, ",")
|
||||
items := make([]int32, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
@@ -524,11 +1024,11 @@ func parseInt32List(value string) []int32 {
|
||||
}
|
||||
parsed, err := strconv.ParseInt(item, 10, 32)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, fmt.Errorf("invalid item handle %q: %w", item, err)
|
||||
}
|
||||
items = append(items, int32(parsed))
|
||||
}
|
||||
return items
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
||||
@@ -647,6 +1147,36 @@ func writeBulkOutput(stdout io.Writer, jsonOutput bool, command string, options
|
||||
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 {
|
||||
encoder := json.NewEncoder(writer)
|
||||
encoder.SetIndent("", " ")
|
||||
@@ -666,7 +1196,67 @@ type protojsonMessage interface {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -2,9 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
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) {
|
||||
value, err := parseValue("int32", "123")
|
||||
if err != nil {
|
||||
@@ -56,3 +90,207 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +824,260 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
|
||||
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
|
||||
|
||||
const file_galaxy_repository_proto_rawDesc = "" +
|
||||
@@ -897,12 +1151,35 @@ const file_galaxy_repository_proto_rawDesc = "" +
|
||||
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
|
||||
"\ris_historized\x18\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" +
|
||||
"\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" +
|
||||
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
|
||||
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
|
||||
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n" +
|
||||
"\x0eBrowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
|
||||
|
||||
var (
|
||||
file_galaxy_repository_proto_rawDescOnce sync.Once
|
||||
@@ -916,7 +1193,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
|
||||
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{
|
||||
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
|
||||
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
|
||||
@@ -928,30 +1205,35 @@ var file_galaxy_repository_proto_goTypes = []any{
|
||||
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
|
||||
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
|
||||
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
|
||||
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
|
||||
(*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
|
||||
(*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{
|
||||
10, // 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
|
||||
12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||
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
|
||||
10, // 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
|
||||
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||
12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
|
||||
12, // 4: galaxy_repository.v1.DeployEvent.observed_at: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
|
||||
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
||||
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
||||
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
||||
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
||||
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
||||
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
||||
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
||||
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
||||
11, // [11:15] is the sub-list for method output_type
|
||||
7, // [7:11] is the sub-list for method input_type
|
||||
7, // [7:7] is the sub-list for extension type_name
|
||||
7, // [7:7] is the sub-list for extension extendee
|
||||
0, // [0:7] is the sub-list for field type_name
|
||||
8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
|
||||
0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
||||
2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
||||
4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
||||
6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
||||
10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
|
||||
1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
||||
3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
||||
5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
||||
7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
||||
11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
|
||||
13, // [13:18] is the sub-list for method output_type
|
||||
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() }
|
||||
@@ -964,13 +1246,18 @@ func file_galaxy_repository_proto_init() {
|
||||
(*DiscoverHierarchyRequest_RootTagName)(nil),
|
||||
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
|
||||
}
|
||||
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
|
||||
(*BrowseChildrenRequest_ParentGobjectId)(nil),
|
||||
(*BrowseChildrenRequest_ParentTagName)(nil),
|
||||
(*BrowseChildrenRequest_ParentContainedPath)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 10,
|
||||
NumMessages: 12,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v7.34.1
|
||||
// source: galaxy_repository.proto
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
|
||||
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
|
||||
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
|
||||
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
|
||||
)
|
||||
|
||||
// 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
|
||||
// older events because the client was too slow.
|
||||
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 {
|
||||
@@ -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.
|
||||
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.
|
||||
// All implementations must embed UnimplementedGalaxyRepositoryServer
|
||||
// for forward compatibility.
|
||||
@@ -122,6 +138,11 @@ type GalaxyRepositoryServer interface {
|
||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||
// older events because the client was too slow.
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -144,6 +165,9 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
|
||||
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
|
||||
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) 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.
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -249,6 +291,10 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DiscoverHierarchy",
|
||||
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BrowseChildren",
|
||||
Handler: _GalaxyRepository_BrowseChildren_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
|
||||
@@ -725,9 +725,10 @@ func (SessionState) EnumDescriptor() ([]byte, []int) {
|
||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
// Public request shape for QueryActiveAlarms. session_id is currently unused
|
||||
// (the snapshot is session-less) but reserved so a future per-session view
|
||||
// can be added without a wire break.
|
||||
// Public request shape for QueryActiveAlarms.
|
||||
// Clients may leave `session_id` empty; the gateway currently ignores it and
|
||||
// serves the session-less central-monitor cache. A future version may use it
|
||||
// to scope the snapshot to one session.
|
||||
type QueryActiveAlarmsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v7.34.1
|
||||
// source: mxaccess_gateway.proto
|
||||
|
||||
@@ -50,6 +50,9 @@ type MxAccessGatewayClient interface {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -180,6 +183,9 @@ type MxAccessGatewayServer interface {
|
||||
// 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
|
||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||
}
|
||||
|
||||
@@ -51,3 +51,26 @@ func (c *Client) QueryActiveAlarms(ctx context.Context, req *QueryActiveAlarmsRe
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -147,8 +147,8 @@ func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
|
||||
SessionId: "session-1",
|
||||
AlarmFilterPrefix: "Tank01.",
|
||||
SessionId: "session-1",
|
||||
AlarmFilterPrefix: "Tank01.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryActiveAlarms() error = %v", err)
|
||||
@@ -168,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 {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
|
||||
@@ -178,6 +238,10 @@ type fakeGatewayWithAlarms struct {
|
||||
|
||||
queryRequest *pb.QueryActiveAlarmsRequest
|
||||
activeSnapshots []*pb.ActiveAlarmSnapshot
|
||||
|
||||
streamRequest *pb.StreamAlarmsRequest
|
||||
feedMessages []*pb.AlarmFeedMessage
|
||||
streamAuth string
|
||||
}
|
||||
|
||||
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
|
||||
@@ -207,6 +271,17 @@ func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsReque
|
||||
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()) {
|
||||
t.Helper()
|
||||
listener := bufconn.Listen(bufSize)
|
||||
|
||||
@@ -222,10 +222,22 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials
|
||||
return credentials.NewTLS(cfg), nil
|
||||
}
|
||||
|
||||
return credentials.NewTLS(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: opts.ServerNameOverride,
|
||||
}), nil
|
||||
return credentials.NewTLS(tlsConfigForOptions(opts)), 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.
|
||||
|
||||
@@ -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) {
|
||||
hresult := int32(-2147467259)
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
@@ -13,6 +15,14 @@ import (
|
||||
"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
|
||||
// Galaxy Repository service exposed for callers that need direct contract
|
||||
// access.
|
||||
@@ -40,6 +50,10 @@ type (
|
||||
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
|
||||
// DeployEvent is one Galaxy Repository deploy event.
|
||||
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.
|
||||
@@ -146,16 +160,35 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
|
||||
|
||||
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
||||
// 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) {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||
var objects []*GalaxyObject
|
||||
pageToken := ""
|
||||
seen := map[string]struct{}{}
|
||||
for {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
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
|
||||
@@ -238,6 +271,206 @@ func (c *GalaxyClient) Close() error {
|
||||
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) {
|
||||
timeout := c.opts.CallTimeout
|
||||
if timeout == 0 {
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
"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)
|
||||
fake := &fakeGalaxyServer{
|
||||
deployReply: &pb.GetLastDeployTimeReply{
|
||||
Present: true,
|
||||
TimeOfLastDeploy: timestamppb.New(want),
|
||||
Present: true,
|
||||
TimeOfLastDeploy: timestamppb.New(want),
|
||||
},
|
||||
}
|
||||
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) {
|
||||
fake := &fakeGalaxyServer{failTest: true}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
@@ -370,15 +414,20 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
||||
type fakeGalaxyServer struct {
|
||||
pb.UnimplementedGalaxyRepositoryServer
|
||||
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
watchRequest *pb.WatchDeployEventsRequest
|
||||
watchSendInterval time.Duration
|
||||
watchHoldOpen bool
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
|
||||
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
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) {
|
||||
@@ -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) {
|
||||
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 {
|
||||
return s.discoverReply, nil
|
||||
}
|
||||
@@ -425,3 +480,385 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
|
||||
}
|
||||
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
|
||||
// DialOptions are appended to the gRPC dial options after the defaults.
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -387,6 +388,173 @@ func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemH
|
||||
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.
|
||||
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
||||
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
||||
|
||||
@@ -70,6 +70,32 @@ type (
|
||||
WriteCommand = pb.WriteCommand
|
||||
// Write2Command is the payload of an MXAccess Write2 command.
|
||||
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 = pb.RegisterReply
|
||||
// AddItemReply carries the ItemHandle returned by AddItem.
|
||||
@@ -86,6 +112,11 @@ type (
|
||||
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
|
||||
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
|
||||
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 = pb.ActiveAlarmSnapshot
|
||||
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||
@@ -104,6 +135,10 @@ type AlarmConditionState = pb.AlarmConditionState
|
||||
// QueryActiveAlarms RPC.
|
||||
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.
|
||||
type (
|
||||
// MxCommandKind discriminates which MXAccess command an MxCommand carries.
|
||||
|
||||
@@ -112,6 +112,23 @@ Support:
|
||||
- custom CA certificate file,
|
||||
- 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
|
||||
|
||||
Support both:
|
||||
|
||||
+109
-2
@@ -57,6 +57,16 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
|
||||
}
|
||||
```
|
||||
|
||||
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
|
||||
no `caCertificatePath` accepts whatever certificate the gateway presents (via
|
||||
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
|
||||
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
|
||||
verify against the JVM trust store without pinning. Use `serverNameOverride` /
|
||||
`--server-name-override` when the dialed host differs from the certificate SAN.
|
||||
See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
||||
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||
underlying protobuf messages. `MxGatewayCommandException` and
|
||||
@@ -68,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
|
||||
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
|
||||
|
||||
The Galaxy Repository service is a separate metadata-only gRPC service exposed
|
||||
@@ -110,6 +126,61 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localh
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||
```
|
||||
|
||||
### Browsing lazily
|
||||
|
||||
For UI trees or OPC UA bridges, use `browseChildrenRaw` 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. For most callers the high-level
|
||||
`browse()`/`LazyBrowseNode` walker below is the preferred surface;
|
||||
`browseChildrenRaw` exposes the single underlying RPC when you need direct
|
||||
control of paging.
|
||||
|
||||
```java
|
||||
BrowseChildrenReply reply = galaxy.browseChildrenRaw(
|
||||
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
|
||||
|
||||
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
||||
@@ -173,12 +244,17 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 -
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-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`,
|
||||
`--server-name-override`, `--timeout`, and `--json` on gateway commands. JSON
|
||||
output redacts API keys.
|
||||
`--server-name-override`, `--require-certificate-validation`, `--timeout`, and
|
||||
`--json` on gateway commands. JSON output redacts API keys. TLS is lenient by
|
||||
default (the certificate is not verified unless you pin a CA with `--ca-file`);
|
||||
pass `--require-certificate-validation` to verify the server certificate against
|
||||
the JVM trust store without pinning.
|
||||
|
||||
Use TLS options for a secured gateway:
|
||||
|
||||
@@ -221,6 +297,37 @@ $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||
```
|
||||
|
||||
## 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.1'
|
||||
}
|
||||
````
|
||||
|
||||
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
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -13,7 +13,7 @@ ext {
|
||||
|
||||
subprojects {
|
||||
group = 'com.zb.mom.ww.mxgateway'
|
||||
version = '0.1.0'
|
||||
version = '0.1.1'
|
||||
|
||||
pluginManager.withPlugin('java') {
|
||||
java {
|
||||
@@ -37,4 +37,44 @@ subprojects {
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
}
|
||||
|
||||
pluginManager.withPlugin('maven-publish') {
|
||||
publishing {
|
||||
publications {
|
||||
maven(MavenPublication) {
|
||||
from components.java
|
||||
pom {
|
||||
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||
description = 'MxAccessGateway Java client'
|
||||
scm {
|
||||
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
|
||||
}
|
||||
developers {
|
||||
developer {
|
||||
id = 'dohertj2'
|
||||
name = 'Joseph Doherty'
|
||||
}
|
||||
}
|
||||
licenses {
|
||||
license {
|
||||
name = 'Proprietary'
|
||||
distribution = 'repo'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
name = 'GiteaPackages'
|
||||
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||
credentials {
|
||||
username = System.getenv('GITEA_USERNAME') ?: ''
|
||||
password = System.getenv('GITEA_TOKEN') ?: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ pluginManagement {
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
|
||||
+111
@@ -142,6 +142,37 @@ public final class GalaxyRepositoryGrpc {
|
||||
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
|
||||
*/
|
||||
@@ -246,6 +277,19 @@ public final class GalaxyRepositoryGrpc {
|
||||
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> 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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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_GET_LAST_DEPLOY_TIME = 1;
|
||||
private static final int METHODID_DISCOVER_HIERARCHY = 2;
|
||||
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
|
||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||
@@ -534,6 +633,10 @@ public final class GalaxyRepositoryGrpc {
|
||||
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
|
||||
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
|
||||
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:
|
||||
throw new AssertionError();
|
||||
}
|
||||
@@ -580,6 +683,13 @@ public final class GalaxyRepositoryGrpc {
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -632,6 +742,7 @@ public final class GalaxyRepositoryGrpc {
|
||||
.addMethod(getGetLastDeployTimeMethod())
|
||||
.addMethod(getDiscoverHierarchyMethod())
|
||||
.addMethod(getWatchDeployEventsMethod())
|
||||
.addMethod(getBrowseChildrenMethod())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+3650
-14
File diff suppressed because it is too large
Load Diff
+852
-1
@@ -3,6 +3,7 @@ package com.zb.mom.ww.mxgateway.cli;
|
||||
import com.zb.mom.ww.mxgateway.client.DeployEventStream;
|
||||
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
|
||||
import com.zb.mom.ww.mxgateway.client.MxEventStream;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClientVersion;
|
||||
@@ -14,7 +15,12 @@ import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
||||
import com.google.protobuf.Message;
|
||||
import com.google.protobuf.util.JsonFormat;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -24,13 +30,29 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
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.OnAlarmTransitionEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
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 picocli.CommandLine;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
@@ -99,7 +121,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
||||
static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
||||
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
|
||||
commandLine.addSubcommand("version", new VersionCommand());
|
||||
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
|
||||
@@ -109,16 +131,222 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
|
||||
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("write-bulk", new WriteBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("write2-bulk", new Write2BulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("write-secured-bulk", new WriteSecuredBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("write-secured2-bulk", new WriteSecured2BulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("bench-read-bulk", new BenchReadBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
|
||||
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
|
||||
commandLine.addSubcommand("stream-alarms", new StreamAlarmsCommand(clientFactory));
|
||||
commandLine.addSubcommand("acknowledge-alarm", new AcknowledgeAlarmCommand(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());
|
||||
commandLine.addSubcommand("batch", new BatchCommand(clientFactory));
|
||||
return commandLine;
|
||||
}
|
||||
|
||||
/** Sentinel written to stdout after every command result in batch mode. */
|
||||
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
|
||||
|
||||
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
||||
private static final Object ALARM_FEED_END = new Object();
|
||||
|
||||
/**
|
||||
* Tokenises a single batch-mode stdin line into the argv that the inner
|
||||
* {@link CommandLine} should execute. Honours single-quoted, double-quoted,
|
||||
* and backslash-escaped runs so values that contain spaces (e.g.
|
||||
* {@code --comment "needs verification"}) survive intact — the old
|
||||
* implementation used {@code split("\\s+")} which shredded any quoted
|
||||
* argument mid-string (Client.Java-034).
|
||||
*
|
||||
* <p>Rules (a small POSIX-like shell tokenizer; no variable expansion,
|
||||
* command substitution, globbing, or backtick handling):
|
||||
*
|
||||
* <ul>
|
||||
* <li>Outside quotes, runs of whitespace separate tokens.</li>
|
||||
* <li>{@code "..."} groups a sequence into one token; the surrounding
|
||||
* quotes are removed. Inside double quotes a backslash escapes
|
||||
* {@code \\}, {@code "}, and a literal newline; other characters
|
||||
* are taken literally (so {@code \n} is the two characters
|
||||
* backslash-n).</li>
|
||||
* <li>{@code '...'} groups a sequence into one token; the surrounding
|
||||
* quotes are removed. Inside single quotes nothing is escaped —
|
||||
* the run is literal until the matching single quote.</li>
|
||||
* <li>Outside quotes, backslash escapes the next character (including
|
||||
* whitespace, so {@code needs\ verification} is one token).</li>
|
||||
* <li>An unterminated quote or a trailing backslash throws
|
||||
* {@link IllegalArgumentException} so the batch loop surfaces it
|
||||
* as a JSON error instead of silently emitting wrong args.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Empty input (or input that contains only whitespace) returns an
|
||||
* empty array so callers can skip the line.
|
||||
*/
|
||||
static String[] tokenizeBatchLine(String line) {
|
||||
List<String> tokens = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
boolean inToken = false;
|
||||
// 0 = outside, 1 = inside single quotes, 2 = inside double quotes
|
||||
int quoteMode = 0;
|
||||
int length = line.length();
|
||||
for (int i = 0; i < length; i++) {
|
||||
char c = line.charAt(i);
|
||||
if (quoteMode == 1) {
|
||||
if (c == '\'') {
|
||||
quoteMode = 0;
|
||||
} else {
|
||||
current.append(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quoteMode == 2) {
|
||||
if (c == '\\') {
|
||||
if (i + 1 >= length) {
|
||||
throw new IllegalArgumentException(
|
||||
"batch tokenizer: trailing backslash inside double-quoted string");
|
||||
}
|
||||
char next = line.charAt(i + 1);
|
||||
if (next == '\\' || next == '"' || next == '\n') {
|
||||
current.append(next);
|
||||
i++;
|
||||
} else {
|
||||
// POSIX rule: inside double quotes a backslash is
|
||||
// literal unless it precedes \, ", $, `, or newline.
|
||||
current.append(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
quoteMode = 0;
|
||||
continue;
|
||||
}
|
||||
current.append(c);
|
||||
continue;
|
||||
}
|
||||
// Outside any quotes.
|
||||
if (c == '\'') {
|
||||
quoteMode = 1;
|
||||
inToken = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
quoteMode = 2;
|
||||
inToken = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\') {
|
||||
if (i + 1 >= length) {
|
||||
throw new IllegalArgumentException(
|
||||
"batch tokenizer: trailing backslash outside quotes");
|
||||
}
|
||||
current.append(line.charAt(i + 1));
|
||||
i++;
|
||||
inToken = true;
|
||||
continue;
|
||||
}
|
||||
if (Character.isWhitespace(c)) {
|
||||
if (inToken) {
|
||||
tokens.add(current.toString());
|
||||
current.setLength(0);
|
||||
inToken = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.append(c);
|
||||
inToken = true;
|
||||
}
|
||||
if (quoteMode != 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"batch tokenizer: unterminated " + (quoteMode == 1 ? "single" : "double") + " quote");
|
||||
}
|
||||
if (inToken) {
|
||||
tokens.add(current.toString());
|
||||
}
|
||||
return tokens.toArray(new String[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one CLI invocation per stdin line, executes each via a fresh
|
||||
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
|
||||
* every result. Errors are written as JSON to stdout so the harness
|
||||
* sees them in the same stream, delimited by the same sentinel. The
|
||||
* loop never terminates on command failure; only stdin EOF (or an
|
||||
* empty line) ends the session.
|
||||
*/
|
||||
@Command(name = "batch", description = "Reads CLI invocations from stdin and executes them sequentially.")
|
||||
static final class BatchCommand implements Callable<Integer> {
|
||||
private final MxGatewayCliClientFactory clientFactory;
|
||||
|
||||
@Spec
|
||||
private CommandSpec spec;
|
||||
|
||||
BatchCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
PrintWriter out = spec.commandLine().getOut();
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
String[] args = tokenizeBatchLine(line);
|
||||
if (args.length == 0) {
|
||||
continue;
|
||||
}
|
||||
StringWriter cmdOut = new StringWriter();
|
||||
StringWriter cmdErr = new StringWriter();
|
||||
PrintWriter cmdOutWriter = new PrintWriter(cmdOut, true);
|
||||
PrintWriter cmdErrWriter = new PrintWriter(cmdErr, true);
|
||||
try {
|
||||
CommandLine cmd = commandLine(clientFactory);
|
||||
cmd.setOut(cmdOutWriter);
|
||||
cmd.setErr(cmdErrWriter);
|
||||
int exitCode = cmd.execute(args);
|
||||
cmdOutWriter.flush();
|
||||
cmdErrWriter.flush();
|
||||
String cmdOutput = cmdOut.toString();
|
||||
if (!cmdOutput.isEmpty()) {
|
||||
out.print(cmdOutput);
|
||||
}
|
||||
if (exitCode != 0) {
|
||||
// Non-zero exit: emit the stderr content (if any) as a JSON
|
||||
// error object to stdout so the harness can parse it in the
|
||||
// same delimited stream.
|
||||
String errText = cmdErr.toString().trim();
|
||||
if (errText.isEmpty()) {
|
||||
errText = "command exited with code " + exitCode;
|
||||
}
|
||||
Map<String, Object> errorPayload = new LinkedHashMap<>();
|
||||
errorPayload.put("error", errText);
|
||||
errorPayload.put("type", "error");
|
||||
out.println(jsonObject(errorPayload));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Map<String, Object> errorPayload = new LinkedHashMap<>();
|
||||
errorPayload.put("error", ex.getMessage() != null ? ex.getMessage() : ex.getClass().getName());
|
||||
errorPayload.put("type", "error");
|
||||
out.println(jsonObject(errorPayload));
|
||||
}
|
||||
out.println(BATCH_EOR);
|
||||
out.flush();
|
||||
}
|
||||
} catch (java.io.IOException ex) {
|
||||
// Stdin closed unexpectedly — treat as EOF and exit normally.
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
abstract static class GalaxyCommand implements Callable<Integer> {
|
||||
@Mixin
|
||||
CommonOptions common = new CommonOptions();
|
||||
@@ -518,6 +746,359 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "read-bulk", description = "Invokes MXAccess ReadBulk (cached or snapshot per tag).")
|
||||
static final class ReadBulkCommand 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 tag addresses.")
|
||||
String items;
|
||||
|
||||
@Option(
|
||||
names = "--timeout-ms",
|
||||
defaultValue = "0",
|
||||
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
|
||||
int timeoutMs;
|
||||
|
||||
ReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
List<BulkReadResult> results = client.session(sessionId)
|
||||
.readBulk(serverHandle, parseStringList(items), Duration.ofMillis(timeoutMs));
|
||||
writeReadBulkOutput("read-bulk", common, json, results);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "write-bulk", description = "Invokes MXAccess WriteBulk.")
|
||||
static final class WriteBulkCommand 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;
|
||||
|
||||
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
|
||||
String type;
|
||||
|
||||
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
|
||||
String values;
|
||||
|
||||
@Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
|
||||
int userId;
|
||||
|
||||
WriteBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
List<Integer> handles = parseIntList(itemHandles);
|
||||
List<String> valueTexts = parseStringList(values);
|
||||
if (handles.size() != valueTexts.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
||||
+ valueTexts.size() + ")");
|
||||
}
|
||||
List<WriteBulkEntry> entries = new ArrayList<>(handles.size());
|
||||
for (int i = 0; i < handles.size(); i++) {
|
||||
entries.add(WriteBulkEntry.newBuilder()
|
||||
.setItemHandle(handles.get(i))
|
||||
.setUserId(userId)
|
||||
.setValue(parseValue(type, valueTexts.get(i)))
|
||||
.build());
|
||||
}
|
||||
List<BulkWriteResult> results = client.session(sessionId).writeBulk(serverHandle, entries);
|
||||
writeWriteBulkOutput("write-bulk", common, json, results);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "write2-bulk", description = "Invokes MXAccess Write2Bulk (timestamped).")
|
||||
static final class Write2BulkCommand 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;
|
||||
|
||||
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
|
||||
String type;
|
||||
|
||||
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
|
||||
String values;
|
||||
|
||||
@Option(names = "--timestamp", required = true, description = "ISO-8601 timestamp shared across all entries.")
|
||||
String timestamp;
|
||||
|
||||
@Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
|
||||
int userId;
|
||||
|
||||
Write2BulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
List<Integer> handles = parseIntList(itemHandles);
|
||||
List<String> valueTexts = parseStringList(values);
|
||||
if (handles.size() != valueTexts.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
||||
+ valueTexts.size() + ")");
|
||||
}
|
||||
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
|
||||
List<Write2BulkEntry> entries = new ArrayList<>(handles.size());
|
||||
for (int i = 0; i < handles.size(); i++) {
|
||||
entries.add(Write2BulkEntry.newBuilder()
|
||||
.setItemHandle(handles.get(i))
|
||||
.setUserId(userId)
|
||||
.setValue(parseValue(type, valueTexts.get(i)))
|
||||
.setTimestampValue(timestampValue)
|
||||
.build());
|
||||
}
|
||||
List<BulkWriteResult> results = client.session(sessionId).write2Bulk(serverHandle, entries);
|
||||
writeWriteBulkOutput("write2-bulk", common, json, results);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "write-secured-bulk", description = "Invokes MXAccess WriteSecuredBulk.")
|
||||
static final class WriteSecuredBulkCommand 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;
|
||||
|
||||
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
|
||||
String type;
|
||||
|
||||
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
|
||||
String values;
|
||||
|
||||
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
|
||||
int currentUserId;
|
||||
|
||||
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
|
||||
int verifierUserId;
|
||||
|
||||
WriteSecuredBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
List<Integer> handles = parseIntList(itemHandles);
|
||||
List<String> valueTexts = parseStringList(values);
|
||||
if (handles.size() != valueTexts.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
||||
+ valueTexts.size() + ")");
|
||||
}
|
||||
List<WriteSecuredBulkEntry> entries = new ArrayList<>(handles.size());
|
||||
for (int i = 0; i < handles.size(); i++) {
|
||||
entries.add(WriteSecuredBulkEntry.newBuilder()
|
||||
.setItemHandle(handles.get(i))
|
||||
.setCurrentUserId(currentUserId)
|
||||
.setVerifierUserId(verifierUserId)
|
||||
.setValue(parseValue(type, valueTexts.get(i)))
|
||||
.build());
|
||||
}
|
||||
List<BulkWriteResult> results = client.session(sessionId).writeSecuredBulk(serverHandle, entries);
|
||||
writeWriteBulkOutput("write-secured-bulk", common, json, results);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "write-secured2-bulk", description = "Invokes MXAccess WriteSecured2Bulk.")
|
||||
static final class WriteSecured2BulkCommand 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;
|
||||
|
||||
@Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
|
||||
String type;
|
||||
|
||||
@Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
|
||||
String values;
|
||||
|
||||
@Option(names = "--timestamp", required = true, description = "ISO-8601 timestamp shared across all entries.")
|
||||
String timestamp;
|
||||
|
||||
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
|
||||
int currentUserId;
|
||||
|
||||
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
|
||||
int verifierUserId;
|
||||
|
||||
WriteSecured2BulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
List<Integer> handles = parseIntList(itemHandles);
|
||||
List<String> valueTexts = parseStringList(values);
|
||||
if (handles.size() != valueTexts.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"item-handles count (" + handles.size() + ") does not match values count ("
|
||||
+ valueTexts.size() + ")");
|
||||
}
|
||||
MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
|
||||
List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size());
|
||||
for (int i = 0; i < handles.size(); i++) {
|
||||
entries.add(WriteSecured2BulkEntry.newBuilder()
|
||||
.setItemHandle(handles.get(i))
|
||||
.setCurrentUserId(currentUserId)
|
||||
.setVerifierUserId(verifierUserId)
|
||||
.setValue(parseValue(type, valueTexts.get(i)))
|
||||
.setTimestampValue(timestampValue)
|
||||
.build());
|
||||
}
|
||||
List<BulkWriteResult> results = client.session(sessionId).writeSecured2Bulk(serverHandle, entries);
|
||||
writeWriteBulkOutput("write-secured2-bulk", common, json, results);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(
|
||||
name = "bench-read-bulk",
|
||||
description = "Repeatedly invokes ReadBulk for benchmarking; prints aggregate timing.")
|
||||
static final class BenchReadBulkCommand 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 tag addresses.")
|
||||
String items;
|
||||
|
||||
@Option(
|
||||
names = "--timeout-ms",
|
||||
defaultValue = "0",
|
||||
description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
|
||||
int timeoutMs;
|
||||
|
||||
@Option(names = "--iterations", defaultValue = "10", description = "Number of ReadBulk calls to perform.")
|
||||
int iterations;
|
||||
|
||||
@Option(
|
||||
names = "--warmup",
|
||||
defaultValue = "1",
|
||||
description = "Number of warmup iterations excluded from timing.")
|
||||
int warmup;
|
||||
|
||||
BenchReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
if (iterations <= 0) {
|
||||
throw new IllegalArgumentException("--iterations must be positive");
|
||||
}
|
||||
if (warmup < 0) {
|
||||
throw new IllegalArgumentException("--warmup must be non-negative");
|
||||
}
|
||||
List<String> tagAddresses = parseStringList(items);
|
||||
Duration timeout = Duration.ofMillis(timeoutMs);
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
MxGatewayCliSession session = client.session(sessionId);
|
||||
for (int i = 0; i < warmup; i++) {
|
||||
session.readBulk(serverHandle, tagAddresses, timeout);
|
||||
}
|
||||
long totalNanos = 0L;
|
||||
long minNanos = Long.MAX_VALUE;
|
||||
long maxNanos = 0L;
|
||||
int lastResultCount = 0;
|
||||
int lastSuccessCount = 0;
|
||||
int lastCachedCount = 0;
|
||||
for (int i = 0; i < iterations; i++) {
|
||||
long start = System.nanoTime();
|
||||
List<BulkReadResult> results = session.readBulk(serverHandle, tagAddresses, timeout);
|
||||
long elapsed = System.nanoTime() - start;
|
||||
totalNanos += elapsed;
|
||||
minNanos = Math.min(minNanos, elapsed);
|
||||
maxNanos = Math.max(maxNanos, elapsed);
|
||||
lastResultCount = results.size();
|
||||
lastSuccessCount = 0;
|
||||
lastCachedCount = 0;
|
||||
for (BulkReadResult result : results) {
|
||||
if (result.getWasSuccessful()) {
|
||||
lastSuccessCount++;
|
||||
}
|
||||
if (result.getWasCached()) {
|
||||
lastCachedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
double avgMs = totalNanos / 1_000_000.0 / iterations;
|
||||
double minMs = minNanos / 1_000_000.0;
|
||||
double maxMs = maxNanos / 1_000_000.0;
|
||||
PrintWriter out = common.spec.commandLine().getOut();
|
||||
if (json) {
|
||||
Map<String, Object> output = new LinkedHashMap<>();
|
||||
output.put("command", "bench-read-bulk");
|
||||
output.put("options", common.redactedJsonMap());
|
||||
output.put("iterations", iterations);
|
||||
output.put("warmup", warmup);
|
||||
output.put("tagCount", tagAddresses.size());
|
||||
output.put("resultCount", lastResultCount);
|
||||
output.put("successCount", lastSuccessCount);
|
||||
output.put("cachedCount", lastCachedCount);
|
||||
output.put("avgMs", avgMs);
|
||||
output.put("minMs", minMs);
|
||||
output.put("maxMs", maxMs);
|
||||
out.println(jsonObject(output));
|
||||
} else {
|
||||
out.printf(
|
||||
"iterations=%d tags=%d avg=%.3fms min=%.3fms max=%.3fms last_results=%d last_success=%d last_cached=%d%n",
|
||||
iterations,
|
||||
tagAddresses.size(),
|
||||
avgMs,
|
||||
minMs,
|
||||
maxMs,
|
||||
lastResultCount,
|
||||
lastSuccessCount,
|
||||
lastCachedCount);
|
||||
}
|
||||
}
|
||||
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.")
|
||||
@@ -591,6 +1172,134 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "stream-alarms", description = "Streams the gateway central alarm feed.")
|
||||
static final class StreamAlarmsCommand extends GatewayCommand {
|
||||
@Option(names = "--filter-prefix", description = "Alarm-reference prefix scoping the feed; empty means unscoped.")
|
||||
String filterPrefix = "";
|
||||
|
||||
@Option(names = "--limit", defaultValue = "0", description = "Maximum feed messages to print.")
|
||||
int limit;
|
||||
|
||||
StreamAlarmsCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
// The async alarm feed delivers on a background gRPC thread; buffer
|
||||
// messages in a bounded queue and drain them on this thread so the
|
||||
// --limit termination mirrors stream-events. 1024 absorbs the
|
||||
// gateway's initial active-alarm snapshot burst.
|
||||
BlockingQueue<Object> queue = new ArrayBlockingQueue<>(1024);
|
||||
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
||||
.setAlarmFilterPrefix(filterPrefix)
|
||||
.build();
|
||||
// Client.Java-033 — fail-fast on overflow. A bare
|
||||
// queue.offer(value) silently drops messages past capacity,
|
||||
// which violates the JavaStyleGuide "do not drop events"
|
||||
// contract and lets the CLI exit 0 on a truncated feed.
|
||||
// Mirrors MxEventStream's overflow branch: detect a failed
|
||||
// offer, cancel the subscription, drain the buffer, then
|
||||
// queue an explicit overflow exception followed by the END
|
||||
// sentinel so the drain loop surfaces a non-zero exit.
|
||||
AtomicReference<MxGatewayAlarmFeedSubscription> subscriptionRef = new AtomicReference<>();
|
||||
MxGatewayAlarmFeedSubscription subscription =
|
||||
client.streamAlarms(request, new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(AlarmFeedMessage value) {
|
||||
if (!queue.offer(value)) {
|
||||
MxGatewayAlarmFeedSubscription sub = subscriptionRef.get();
|
||||
if (sub != null) {
|
||||
sub.cancel();
|
||||
}
|
||||
queue.clear();
|
||||
queue.offer(new IllegalStateException(
|
||||
"stream-alarms queue overflowed (capacity 1024); consumer too slow"));
|
||||
queue.offer(ALARM_FEED_END);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
queue.offer(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
queue.offer(ALARM_FEED_END);
|
||||
}
|
||||
});
|
||||
subscriptionRef.set(subscription);
|
||||
try {
|
||||
int count = 0;
|
||||
while (true) {
|
||||
Object item = queue.take();
|
||||
if (item == ALARM_FEED_END) {
|
||||
break;
|
||||
}
|
||||
if (item instanceof Throwable error) {
|
||||
throw new IllegalStateException(
|
||||
"gateway stream alarms failed: " + error.getMessage(), error);
|
||||
}
|
||||
AlarmFeedMessage message = (AlarmFeedMessage) item;
|
||||
if (json) {
|
||||
client.out().println(protoJson(message));
|
||||
} else {
|
||||
client.out().println(formatAlarmFeedMessage(message));
|
||||
}
|
||||
client.out().flush();
|
||||
count++;
|
||||
if (limit > 0 && count >= limit) {
|
||||
subscription.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
subscription.cancel();
|
||||
} finally {
|
||||
subscription.cancel();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "acknowledge-alarm", description = "Acknowledges an active MXAccess alarm.")
|
||||
static final class AcknowledgeAlarmCommand extends GatewayCommand {
|
||||
@Option(names = "--reference", required = true, description = "Full alarm reference to acknowledge.")
|
||||
String reference;
|
||||
|
||||
@Option(names = "--comment", description = "Operator acknowledge comment.")
|
||||
String comment = "";
|
||||
|
||||
@Option(names = "--operator", description = "Operator user performing the acknowledge.")
|
||||
String operator = "";
|
||||
|
||||
AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
AcknowledgeAlarmReply reply = client.acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference(reference)
|
||||
.setComment(comment)
|
||||
.setOperatorUser(operator)
|
||||
.build());
|
||||
writeOutput(
|
||||
"acknowledge-alarm",
|
||||
common,
|
||||
json,
|
||||
reply,
|
||||
() -> Integer.toString(reply.getHresult()));
|
||||
}
|
||||
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.")
|
||||
@@ -658,6 +1367,13 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
@Option(names = "--server-name-override", description = "TLS server name override.")
|
||||
String serverNameOverride = "";
|
||||
|
||||
@Option(
|
||||
names = "--require-certificate-validation",
|
||||
description =
|
||||
"Verify the server certificate against the JVM trust store "
|
||||
+ "(disables the lenient default; ignored with --plaintext or --ca-file pinning).")
|
||||
boolean requireCertificateValidation;
|
||||
|
||||
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
|
||||
String timeout;
|
||||
|
||||
@@ -680,6 +1396,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
.plaintext(plaintext)
|
||||
.caCertificatePath(caFile)
|
||||
.serverNameOverride(serverNameOverride)
|
||||
.requireCertificateValidation(requireCertificateValidation)
|
||||
.callTimeout(resolvedTimeout)
|
||||
.build();
|
||||
}
|
||||
@@ -692,6 +1409,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
values.put("plaintext", plaintext);
|
||||
values.put("caFile", caFile == null ? "" : caFile.toString());
|
||||
values.put("serverNameOverride", serverNameOverride);
|
||||
values.put("requireCertificateValidation", requireCertificateValidation);
|
||||
values.put("timeout", timeout);
|
||||
return values;
|
||||
}
|
||||
@@ -710,6 +1428,11 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
|
||||
MxGatewayCliSession session(String sessionId);
|
||||
|
||||
AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request);
|
||||
|
||||
MxGatewayAlarmFeedSubscription streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer);
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -733,6 +1456,16 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
|
||||
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
|
||||
|
||||
List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout);
|
||||
|
||||
List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries);
|
||||
|
||||
List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries);
|
||||
|
||||
List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries);
|
||||
|
||||
List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries);
|
||||
|
||||
MxEventStream streamEventsAfter(long afterWorkerSequence);
|
||||
}
|
||||
|
||||
@@ -772,6 +1505,17 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
return new GrpcMxGatewayCliSession(MxGatewaySession.forSessionId(client, sessionId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||
return client.acknowledgeAlarm(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||
return client.streamAlarms(request, observer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
client.close();
|
||||
@@ -824,6 +1568,31 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
return session.unsubscribeBulk(serverHandle, itemHandles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
|
||||
return session.readBulk(serverHandle, items, timeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||
return session.writeBulk(serverHandle, entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||
return session.write2Bulk(serverHandle, entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||
return session.writeSecuredBulk(serverHandle, entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||
return session.writeSecured2Bulk(serverHandle, entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
|
||||
return session.streamEventsAfter(afterWorkerSequence);
|
||||
@@ -872,6 +1641,88 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void writeWriteBulkOutput(
|
||||
String command, CommonOptions common, boolean json, List<BulkWriteResult> 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::bulkWriteResultMap).toList());
|
||||
out.println(jsonObject(output));
|
||||
return;
|
||||
}
|
||||
out.println(results.size());
|
||||
}
|
||||
|
||||
private static Map<String, Object> bulkWriteResultMap(BulkWriteResult result) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
values.put("serverHandle", result.getServerHandle());
|
||||
values.put("itemHandle", result.getItemHandle());
|
||||
values.put("wasSuccessful", result.getWasSuccessful());
|
||||
values.put("hresult", result.hasHresult() ? (Object) result.getHresult() : null);
|
||||
values.put("errorMessage", result.getErrorMessage());
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void writeReadBulkOutput(
|
||||
String command, CommonOptions common, boolean json, List<BulkReadResult> 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::bulkReadResultMap).toList());
|
||||
out.println(jsonObject(output));
|
||||
return;
|
||||
}
|
||||
out.println(results.size());
|
||||
}
|
||||
|
||||
private static Map<String, Object> bulkReadResultMap(BulkReadResult 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("wasCached", result.getWasCached());
|
||||
values.put("quality", result.getQuality());
|
||||
values.put("errorMessage", result.getErrorMessage());
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one {@link AlarmFeedMessage} in the CLI's plain-text output
|
||||
* style, distinguishing the active-alarm snapshot, snapshot-complete
|
||||
* sentinel, and transition cases of the message's {@code payload} oneof.
|
||||
*/
|
||||
private static String formatAlarmFeedMessage(AlarmFeedMessage message) {
|
||||
return switch (message.getPayloadCase()) {
|
||||
case ACTIVE_ALARM -> {
|
||||
ActiveAlarmSnapshot alarm = message.getActiveAlarm();
|
||||
yield String.format(
|
||||
"active-alarm %s state=%s severity=%d",
|
||||
alarm.getAlarmFullReference(), alarm.getCurrentState().name(), alarm.getSeverity());
|
||||
}
|
||||
case SNAPSHOT_COMPLETE -> "snapshot-complete";
|
||||
case TRANSITION -> {
|
||||
OnAlarmTransitionEvent transition = message.getTransition();
|
||||
yield String.format(
|
||||
"transition %s kind=%s severity=%d",
|
||||
transition.getAlarmFullReference(),
|
||||
transition.getTransitionKind().name(),
|
||||
transition.getSeverity());
|
||||
}
|
||||
case PROVIDER_STATUS -> {
|
||||
AlarmProviderStatus status = message.getProviderStatus();
|
||||
yield String.format(
|
||||
"provider-status mode=%s degraded=%b reason=%s",
|
||||
status.getMode().name(), status.getDegraded(), status.getReason());
|
||||
}
|
||||
case PAYLOAD_NOT_SET -> "unknown";
|
||||
};
|
||||
}
|
||||
|
||||
private static MxValue parseValue(String type, String text) {
|
||||
return switch (type) {
|
||||
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
|
||||
|
||||
+507
@@ -4,24 +4,45 @@ 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 com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
|
||||
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 {
|
||||
@@ -141,6 +162,301 @@ final class MxGatewayCliTests {
|
||||
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 requireCertificateValidationFlagPropagatesThroughToClientOptions() {
|
||||
// Client.Java-038 regression — the --require-certificate-validation
|
||||
// CLI flag must reach MxGatewayClientOptions.requireCertificateValidation
|
||||
// via CommonOptions.toClientOptions(), so CLI users can opt into strict
|
||||
// JVM-trust verification without pinning a CA.
|
||||
CapturingClientFactory factory = new CapturingClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"acknowledge-alarm",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--api-key-env",
|
||||
"MXGATEWAY_API_KEY",
|
||||
"--require-certificate-validation",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi");
|
||||
|
||||
assertEquals(0, run.exitCode(), "errors:\n" + run.errors());
|
||||
assertTrue(
|
||||
factory.capturedClientOptions.requireCertificateValidation(),
|
||||
"--require-certificate-validation did not propagate into MxGatewayClientOptions");
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireCertificateValidationDefaultsToLenientWhenFlagAbsent() {
|
||||
// Without the flag, the lenient-by-default trust posture must be
|
||||
// preserved (requireCertificateValidation == false).
|
||||
CapturingClientFactory factory = new CapturingClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"acknowledge-alarm",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--api-key-env",
|
||||
"MXGATEWAY_API_KEY",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi");
|
||||
|
||||
assertEquals(0, run.exitCode(), "errors:\n" + run.errors());
|
||||
assertFalse(
|
||||
factory.capturedClientOptions.requireCertificateValidation(),
|
||||
"requireCertificateValidation should default to false (lenient)");
|
||||
}
|
||||
|
||||
@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();
|
||||
@@ -165,10 +481,100 @@ final class MxGatewayCliTests {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that records the {@link MxGatewayClientOptions} produced by
|
||||
* {@link MxGatewayCli.CommonOptions#toClientOptions()} so a test can assert
|
||||
* how CLI flags map onto the library option surface. Wraps the standard
|
||||
* {@link FakeClient} so the command body still completes. Used by the
|
||||
* Client.Java-038 option-flow regression.
|
||||
*/
|
||||
private static final class CapturingClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
||||
private MxGatewayClientOptions capturedClientOptions;
|
||||
|
||||
@Override
|
||||
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
||||
capturedClientOptions = options.toClientOptions();
|
||||
return new FakeClient(options.spec.commandLine().getOut());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -202,6 +608,40 @@ final class MxGatewayCliTests {
|
||||
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() {
|
||||
}
|
||||
@@ -295,6 +735,73 @@ final class MxGatewayCliTests {
|
||||
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");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'com.google.protobuf'
|
||||
id 'maven-publish'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -30,6 +31,11 @@ sourceSets {
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Filters and shape options for {@link GalaxyRepositoryClient#browse(BrowseChildrenOptions)}.
|
||||
* Mirror of the existing DiscoverHierarchy options for the lazy-browse path.
|
||||
*
|
||||
* <p>All filter fields are AND-combined server-side. Empty / unset fields disable
|
||||
* that filter. The {@code includeAttributes} tri-state uses {@code null} to mean
|
||||
* "let the server use its default"; non-{@code null} forwards the explicit flag.
|
||||
*/
|
||||
public final class BrowseChildrenOptions {
|
||||
private final List<Integer> categoryIds;
|
||||
private final List<String> templateChainContains;
|
||||
private final String tagNameGlob;
|
||||
private final Boolean includeAttributes;
|
||||
private final boolean alarmBearingOnly;
|
||||
private final boolean historizedOnly;
|
||||
|
||||
private BrowseChildrenOptions(Builder b) {
|
||||
this.categoryIds = List.copyOf(b.categoryIds);
|
||||
this.templateChainContains = List.copyOf(b.templateChainContains);
|
||||
this.tagNameGlob = b.tagNameGlob;
|
||||
this.includeAttributes = b.includeAttributes;
|
||||
this.alarmBearingOnly = b.alarmBearingOnly;
|
||||
this.historizedOnly = b.historizedOnly;
|
||||
}
|
||||
|
||||
/** @return immutable list of category IDs to include; empty disables this filter. */
|
||||
public List<Integer> getCategoryIds() { return categoryIds; }
|
||||
|
||||
/** @return immutable list of template names that must appear in each child's template chain. */
|
||||
public List<String> getTemplateChainContains() { return templateChainContains; }
|
||||
|
||||
/** @return SQL-LIKE-style glob applied to {@code tag_name}; empty disables. */
|
||||
public String getTagNameGlob() { return tagNameGlob; }
|
||||
|
||||
/** @return tri-state override for {@code include_attributes}; {@code null} keeps the server default. */
|
||||
public Boolean getIncludeAttributes() { return includeAttributes; }
|
||||
|
||||
/** @return restrict to alarm-bearing objects. */
|
||||
public boolean isAlarmBearingOnly() { return alarmBearingOnly; }
|
||||
|
||||
/** @return restrict to objects with at least one historized attribute. */
|
||||
public boolean isHistorizedOnly() { return historizedOnly; }
|
||||
|
||||
/** @return a fresh builder. */
|
||||
public static Builder builder() { return new Builder(); }
|
||||
|
||||
/** @return options with every filter disabled and {@code includeAttributes} unset. */
|
||||
public static BrowseChildrenOptions empty() { return builder().build(); }
|
||||
|
||||
/** Fluent builder for {@link BrowseChildrenOptions}. */
|
||||
public static final class Builder {
|
||||
private List<Integer> categoryIds = Collections.emptyList();
|
||||
private List<String> templateChainContains = Collections.emptyList();
|
||||
private String tagNameGlob = "";
|
||||
private Boolean includeAttributes = null;
|
||||
private boolean alarmBearingOnly = false;
|
||||
private boolean historizedOnly = false;
|
||||
|
||||
/** Sets the category-id filter. */
|
||||
public Builder categoryIds(List<Integer> v) {
|
||||
this.categoryIds = v == null ? Collections.emptyList() : v;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets the template-chain-contains filter. */
|
||||
public Builder templateChainContains(List<String> v) {
|
||||
this.templateChainContains = v == null ? Collections.emptyList() : v;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets the tag-name glob. */
|
||||
public Builder tagNameGlob(String v) {
|
||||
this.tagNameGlob = v == null ? "" : v;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets the tri-state {@code includeAttributes} override; {@code null} keeps the server default. */
|
||||
public Builder includeAttributes(Boolean v) {
|
||||
this.includeAttributes = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Toggles the alarm-bearing-only filter. */
|
||||
public Builder alarmBearingOnly(boolean v) {
|
||||
this.alarmBearingOnly = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Toggles the historized-only filter. */
|
||||
public Builder historizedOnly(boolean v) {
|
||||
this.historizedOnly = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Builds the immutable options. */
|
||||
public BrowseChildrenOptions build() {
|
||||
return new BrowseChildrenOptions(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-52
@@ -2,64 +2,19 @@ package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.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.
|
||||
*
|
||||
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||
*/
|
||||
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();
|
||||
public final class DeployEventSubscription
|
||||
extends MxGatewayStreamSubscription<WatchDeployEventsRequest, DeployEvent> {
|
||||
public DeployEventSubscription() {
|
||||
super("client cancelled deploy event stream");
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -4,6 +4,8 @@ import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||
@@ -37,6 +39,7 @@ import javax.net.ssl.SSLException;
|
||||
*/
|
||||
public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
||||
private static final int BROWSE_CHILDREN_PAGE_SIZE = 500;
|
||||
|
||||
private final ManagedChannel ownedChannel;
|
||||
private final MxGatewayClientOptions options;
|
||||
@@ -213,6 +216,98 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-browse entry point: fetches the root layer of the Galaxy hierarchy.
|
||||
* Each returned {@link LazyBrowseNode} can be expanded on demand via
|
||||
* {@link LazyBrowseNode#expand()} to load its direct children.
|
||||
*
|
||||
* @return the root nodes (no parent selector) with default options
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public List<LazyBrowseNode> browse() {
|
||||
return browse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-browse entry point with caller-supplied filters / shape.
|
||||
*
|
||||
* @param options filter and shape options; {@code null} means {@link BrowseChildrenOptions#empty()}
|
||||
* @return the root nodes matching the options
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public List<LazyBrowseNode> browse(BrowseChildrenOptions options) {
|
||||
BrowseChildrenOptions effective = options == null ? BrowseChildrenOptions.empty() : options;
|
||||
return browseChildrenInner(null, effective);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a single {@code BrowseChildren} RPC and returns the raw reply.
|
||||
* Callers wanting full control over pagination can drive the loop themselves.
|
||||
*
|
||||
* @param request the request to send
|
||||
* @return the reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public BrowseChildrenReply browseChildrenRaw(BrowseChildrenRequest request) {
|
||||
try {
|
||||
return rawBlockingStub().browseChildren(request);
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("galaxy browse children", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the BrowseChildren paging loop for a single parent (or roots when
|
||||
* {@code parentGobjectId} is {@code null}). Detects repeated page tokens to
|
||||
* avoid infinite loops on a buggy server.
|
||||
*/
|
||||
List<LazyBrowseNode> browseChildrenInner(Integer parentGobjectId, BrowseChildrenOptions options) {
|
||||
java.util.ArrayList<LazyBrowseNode> nodes = new java.util.ArrayList<>();
|
||||
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
|
||||
String pageToken = "";
|
||||
while (true) {
|
||||
BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder()
|
||||
.setPageSize(BROWSE_CHILDREN_PAGE_SIZE)
|
||||
.setPageToken(pageToken)
|
||||
.setAlarmBearingOnly(options.isAlarmBearingOnly())
|
||||
.setHistorizedOnly(options.isHistorizedOnly());
|
||||
if (parentGobjectId != null) {
|
||||
builder.setParentGobjectId(parentGobjectId.intValue());
|
||||
}
|
||||
if (!options.getCategoryIds().isEmpty()) {
|
||||
builder.addAllCategoryIds(options.getCategoryIds());
|
||||
}
|
||||
if (!options.getTemplateChainContains().isEmpty()) {
|
||||
builder.addAllTemplateChainContains(options.getTemplateChainContains());
|
||||
}
|
||||
if (!options.getTagNameGlob().isEmpty()) {
|
||||
builder.setTagNameGlob(options.getTagNameGlob());
|
||||
}
|
||||
if (options.getIncludeAttributes() != null) {
|
||||
builder.setIncludeAttributes(options.getIncludeAttributes());
|
||||
}
|
||||
|
||||
BrowseChildrenReply reply = browseChildrenRaw(builder.build());
|
||||
|
||||
for (int i = 0; i < reply.getChildrenCount(); i++) {
|
||||
boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i);
|
||||
nodes.add(new LazyBrowseNode(this, reply.getChildren(i), hint, options));
|
||||
}
|
||||
|
||||
pageToken = reply.getNextPageToken();
|
||||
if (pageToken == null || pageToken.isEmpty()) {
|
||||
return nodes;
|
||||
}
|
||||
if (!seenPageTokens.add(pageToken)) {
|
||||
throw new MxGatewayException(
|
||||
"galaxy browse children returned repeated page token: " + pageToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to {@code WatchDeployEvents} via the async stub and consumes
|
||||
* results through a blocking iterator. Closing the returned stream cancels
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
/**
|
||||
* One node in a lazy-loaded Galaxy browse tree. Holds the underlying
|
||||
* {@link GalaxyObject} and exposes {@link #expand()} to fetch its direct
|
||||
* children on demand. Expansion is one-shot: a second call is a no-op.
|
||||
* Pagination of large sibling sets is handled internally by the client.
|
||||
*/
|
||||
public final class LazyBrowseNode {
|
||||
private final GalaxyRepositoryClient client;
|
||||
private final GalaxyObject object;
|
||||
private final boolean hasChildrenHint;
|
||||
private final BrowseChildrenOptions options;
|
||||
|
||||
// expandLock gates the start of a new expand AND the publish of the in-flight
|
||||
// future. Readers (getChildren / isExpanded) use a separate read-write lock so
|
||||
// they never block on the gRPC call.
|
||||
private final Object expandLock = new Object();
|
||||
private CompletableFuture<Void> inFlight;
|
||||
|
||||
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
|
||||
private List<LazyBrowseNode> children = Collections.emptyList();
|
||||
private boolean isExpanded;
|
||||
|
||||
LazyBrowseNode(
|
||||
GalaxyRepositoryClient client,
|
||||
GalaxyObject object,
|
||||
boolean hasChildrenHint,
|
||||
BrowseChildrenOptions options) {
|
||||
this.client = client;
|
||||
this.object = object;
|
||||
this.hasChildrenHint = hasChildrenHint;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/** @return the underlying Galaxy object proto for this node. */
|
||||
public GalaxyObject getObject() {
|
||||
return object;
|
||||
}
|
||||
|
||||
/** @return {@code true} when the server reports this node has at least one matching descendant. */
|
||||
public boolean hasChildrenHint() {
|
||||
return hasChildrenHint;
|
||||
}
|
||||
|
||||
/** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
|
||||
public List<LazyBrowseNode> getChildren() {
|
||||
readWriteLock.readLock().lock();
|
||||
try {
|
||||
return List.copyOf(children);
|
||||
} finally {
|
||||
readWriteLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return {@code true} after the first {@link #expand()} call completes. */
|
||||
public boolean isExpanded() {
|
||||
readWriteLock.readLock().lock();
|
||||
try {
|
||||
return isExpanded;
|
||||
} finally {
|
||||
readWriteLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches direct children from the gateway and populates {@link #getChildren()}.
|
||||
* Idempotent: subsequent calls are no-ops and do not issue a second RPC.
|
||||
*
|
||||
* <p>Concurrent callers coalesce onto a single in-flight RPC: the first caller
|
||||
* (the "leader") issues the gRPC call, while any other thread that calls
|
||||
* {@code expand()} during that window blocks on the leader's future and sees
|
||||
* the same result (or the same exception). On failure the in-flight slot is
|
||||
* cleared so a subsequent call can retry.
|
||||
*
|
||||
* <p>Readers ({@link #getChildren()} / {@link #isExpanded()}) take a separate
|
||||
* read lock and are never blocked for the duration of the RPC.
|
||||
*
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public void expand() {
|
||||
if (isExpanded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
CompletableFuture<Void> future;
|
||||
boolean iAmTheLeader;
|
||||
synchronized (expandLock) {
|
||||
if (isExpanded()) {
|
||||
return;
|
||||
}
|
||||
if (inFlight != null) {
|
||||
future = inFlight;
|
||||
iAmTheLeader = false;
|
||||
} else {
|
||||
future = new CompletableFuture<>();
|
||||
inFlight = future;
|
||||
iAmTheLeader = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (iAmTheLeader) {
|
||||
try {
|
||||
List<LazyBrowseNode> loaded =
|
||||
client.browseChildrenInner(object.getGobjectId(), options);
|
||||
readWriteLock.writeLock().lock();
|
||||
try {
|
||||
this.children = loaded;
|
||||
this.isExpanded = true;
|
||||
} finally {
|
||||
readWriteLock.writeLock().unlock();
|
||||
}
|
||||
synchronized (expandLock) {
|
||||
inFlight = null;
|
||||
}
|
||||
future.complete(null);
|
||||
} catch (RuntimeException ex) {
|
||||
synchronized (expandLock) {
|
||||
inFlight = null;
|
||||
}
|
||||
future.completeExceptionally(ex);
|
||||
throw ex;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
future.get();
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new MxGatewayException("Interrupted waiting for browse-children expand.", ie);
|
||||
} catch (ExecutionException ee) {
|
||||
Throwable cause = ee.getCause();
|
||||
if (cause instanceof MxGatewayException me) {
|
||||
throw me;
|
||||
}
|
||||
if (cause instanceof RuntimeException re) {
|
||||
throw re;
|
||||
}
|
||||
throw new MxGatewayException("BrowseChildren expand failed.", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-51
@@ -1,10 +1,6 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
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;
|
||||
|
||||
@@ -15,53 +11,13 @@ import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||
* {@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.
|
||||
*
|
||||
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||
*/
|
||||
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();
|
||||
public final class MxGatewayActiveAlarmsSubscription
|
||||
extends MxGatewayStreamSubscription<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> {
|
||||
public MxGatewayActiveAlarmsSubscription() {
|
||||
super("client cancelled active-alarms query");
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
|
||||
/**
|
||||
* Cancellable handle returned by {@code streamAlarms}.
|
||||
*
|
||||
* <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.
|
||||
*
|
||||
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||
*/
|
||||
public final class MxGatewayAlarmFeedSubscription
|
||||
extends MxGatewayStreamSubscription<StreamAlarmsRequest, AlarmFeedMessage> {
|
||||
public MxGatewayAlarmFeedSubscription() {
|
||||
super("client cancelled alarm feed");
|
||||
}
|
||||
}
|
||||
+45
@@ -18,6 +18,7 @@ import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
@@ -27,6 +28,7 @@ import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
|
||||
/**
|
||||
@@ -320,6 +322,27 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches to the gateway's central alarm feed. The stream opens with one
|
||||
* {@code AlarmFeedMessage} per currently-active alarm (the ConditionRefresh
|
||||
* snapshot), then a single {@code snapshot_complete}, then a
|
||||
* {@code transition} for every subsequent raise / acknowledge / clear.
|
||||
*
|
||||
* <p>Served by the gateway's always-on alarm monitor — no worker session is
|
||||
* opened — so any number of clients may attach.
|
||||
*
|
||||
* @param request the {@code StreamAlarmsRequest}, optionally scoped by
|
||||
* alarm-reference prefix
|
||||
* @param observer caller-supplied observer that receives feed messages and completion
|
||||
* @return a cancellable subscription handle
|
||||
*/
|
||||
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||
MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
|
||||
withStreamDeadline(rawAsyncStub()).streamAlarms(request, subscription.wrap(observer));
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (ownedChannel != null) {
|
||||
@@ -361,6 +384,15 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
} catch (SSLException error) {
|
||||
throw new MxGatewayException("failed to configure gateway TLS", error);
|
||||
}
|
||||
} else if (!options.requireCertificateValidation()) {
|
||||
try {
|
||||
builder.sslContext(GrpcSslContexts.forClient()
|
||||
.trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util
|
||||
.InsecureTrustManagerFactory.INSTANCE)
|
||||
.build());
|
||||
} catch (SSLException error) {
|
||||
throw new MxGatewayException("failed to configure lenient gateway TLS", error);
|
||||
}
|
||||
} else {
|
||||
builder.useTransportSecurity();
|
||||
}
|
||||
@@ -370,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-visible test seam — creates a raw {@link ManagedChannel} from the
|
||||
* given options without attaching auth interceptors. Used by TLS fixture
|
||||
* tests to verify channel construction behaviour without a full
|
||||
* {@link MxGatewayClient} wrapper.
|
||||
*
|
||||
* @param options the client options
|
||||
* @return a new {@link ManagedChannel}
|
||||
*/
|
||||
static ManagedChannel createChannelForTests(MxGatewayClientOptions options) {
|
||||
return createChannel(options);
|
||||
}
|
||||
|
||||
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
||||
if (options.callTimeout().isNegative()) {
|
||||
return stub;
|
||||
|
||||
+32
@@ -20,6 +20,7 @@ public final class MxGatewayClientOptions {
|
||||
private final String apiKey;
|
||||
private final boolean plaintext;
|
||||
private final Path caCertificatePath;
|
||||
private final boolean requireCertificateValidation;
|
||||
private final String serverNameOverride;
|
||||
private final Duration connectTimeout;
|
||||
private final Duration callTimeout;
|
||||
@@ -31,6 +32,7 @@ public final class MxGatewayClientOptions {
|
||||
apiKey = builder.apiKey == null ? "" : builder.apiKey;
|
||||
plaintext = builder.plaintext;
|
||||
caCertificatePath = builder.caCertificatePath;
|
||||
requireCertificateValidation = builder.requireCertificateValidation;
|
||||
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
|
||||
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
||||
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
||||
@@ -95,6 +97,18 @@ public final class MxGatewayClientOptions {
|
||||
return caCertificatePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether TLS certificate verification is required even when no CA is pinned.
|
||||
* When {@code false} (default), the gateway's self-signed certificate is accepted
|
||||
* without verification. When {@code true}, the OS trust store is used.
|
||||
* Pinning a CA via {@link #caCertificatePath()} always verifies regardless of this flag.
|
||||
*
|
||||
* @return {@code true} if strict certificate verification is required
|
||||
*/
|
||||
public boolean requireCertificateValidation() {
|
||||
return requireCertificateValidation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the TLS server-name override, or an empty string when none was supplied.
|
||||
*
|
||||
@@ -148,6 +162,8 @@ public final class MxGatewayClientOptions {
|
||||
+ plaintext
|
||||
+ ", caCertificatePath="
|
||||
+ caCertificatePath
|
||||
+ ", requireCertificateValidation="
|
||||
+ requireCertificateValidation
|
||||
+ ", serverNameOverride='"
|
||||
+ serverNameOverride
|
||||
+ '\''
|
||||
@@ -177,6 +193,7 @@ public final class MxGatewayClientOptions {
|
||||
private String apiKey;
|
||||
private boolean plaintext;
|
||||
private Path caCertificatePath;
|
||||
private boolean requireCertificateValidation;
|
||||
private String serverNameOverride;
|
||||
private Duration connectTimeout;
|
||||
private Duration callTimeout;
|
||||
@@ -230,6 +247,21 @@ public final class MxGatewayClientOptions {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* When {@code true}, TLS connections without a pinned CA use the OS trust store
|
||||
* and will reject the gateway's self-signed certificate. When {@code false}
|
||||
* (default), the gateway certificate is accepted without verification —
|
||||
* appropriate for this internal tool's auto-generated self-signed certificate.
|
||||
* Pinning a CA via {@link #caCertificatePath(Path)} always verifies.
|
||||
*
|
||||
* @param value {@code true} to require certificate validation, {@code false} to accept any cert
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder requireCertificateValidation(boolean value) {
|
||||
requireCertificateValidation = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the TLS server name used during the handshake.
|
||||
*
|
||||
|
||||
+7
-51
@@ -1,10 +1,6 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
|
||||
@@ -15,53 +11,13 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
||||
* try-with-resources blocks.
|
||||
*
|
||||
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||
*/
|
||||
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();
|
||||
public final class MxGatewayEventSubscription
|
||||
extends MxGatewayStreamSubscription<StreamEventsRequest, MxEvent> {
|
||||
public MxGatewayEventSubscription() {
|
||||
super("client cancelled event stream");
|
||||
}
|
||||
}
|
||||
|
||||
+148
@@ -1,6 +1,7 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -9,6 +10,8 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
|
||||
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.MxCommand;
|
||||
@@ -17,6 +20,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
|
||||
@@ -27,8 +31,16 @@ import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
|
||||
|
||||
/**
|
||||
* Typed handle for a single MXAccess gateway session.
|
||||
@@ -421,6 +433,142 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
return reply.getUnsubscribeBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Write} — sequential MXAccess Write per entry on the worker's STA.
|
||||
*
|
||||
* <p>Per-entry failures appear as {@link BulkWriteResult} entries with
|
||||
* {@code wasSuccessful == false}; this method does not throw for per-entry
|
||||
* MXAccess failures (it still throws {@link MxGatewayException} on transport
|
||||
* or protocol-level failures).
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param entries the per-item (handle, value, user id) tuples
|
||||
* @return a per-entry {@link BulkWriteResult} list
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws NullPointerException if {@code entries} is {@code null}
|
||||
*/
|
||||
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK)
|
||||
.setWriteBulk(WriteBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Write2} — sequential MXAccess Write2 (timestamped) per entry.
|
||||
*
|
||||
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param entries the per-item (handle, value, timestamp, user id) tuples
|
||||
* @return a per-entry {@link BulkWriteResult} list
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws NullPointerException if {@code entries} is {@code null}
|
||||
*/
|
||||
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2_BULK)
|
||||
.setWrite2Bulk(Write2BulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWrite2Bulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code WriteSecured} — credential-sensitive values must not be logged
|
||||
* by callers; mirrors the single-item write-secured redaction contract.
|
||||
*
|
||||
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param entries the per-item (handle, value, current+verifier user id) tuples
|
||||
* @return a per-entry {@link BulkWriteResult} list
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws NullPointerException if {@code entries} is {@code null}
|
||||
*/
|
||||
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED_BULK)
|
||||
.setWriteSecuredBulk(WriteSecuredBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteSecuredBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code WriteSecured2} — sequential timestamped + verified write per entry.
|
||||
*
|
||||
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param entries the per-item (handle, value, timestamp, current+verifier user id) tuples
|
||||
* @return a per-entry {@link BulkWriteResult} list
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws NullPointerException if {@code entries} is {@code null}
|
||||
*/
|
||||
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2_BULK)
|
||||
.setWriteSecured2Bulk(WriteSecured2BulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteSecured2Bulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Read} — snapshot the current value of each requested tag.
|
||||
*
|
||||
* <p>MXAccess COM has no synchronous read; the worker returns the cached
|
||||
* {@code OnDataChange} value for any tag that is already advised
|
||||
* ({@code wasCached == true}) without modifying the existing subscription,
|
||||
* and falls back to a full AddItem + Advise + wait + UnAdvise + RemoveItem
|
||||
* snapshot lifecycle otherwise. The supplied {@code timeout} bounds the
|
||||
* per-tag wait in the snapshot case; pass {@link Duration#ZERO} (or
|
||||
* {@code null}) to use the worker default (1000 ms). Per-tag failures
|
||||
* appear as {@link BulkReadResult} entries with {@code wasSuccessful == false};
|
||||
* this method does not throw for per-tag MXAccess failures.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param tagAddresses the tag addresses to read
|
||||
* @param timeout per-tag snapshot timeout (zero or null = worker default)
|
||||
* @return a per-tag {@link BulkReadResult} list
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws NullPointerException if {@code tagAddresses} is {@code null}
|
||||
* @throws IllegalArgumentException if {@code timeout} is negative or exceeds {@link Integer#MAX_VALUE} milliseconds
|
||||
*/
|
||||
public List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout) {
|
||||
Objects.requireNonNull(tagAddresses, "tagAddresses");
|
||||
int timeoutMs = 0;
|
||||
if (timeout != null) {
|
||||
if (timeout.isNegative()) {
|
||||
throw new IllegalArgumentException("timeout must be non-negative");
|
||||
}
|
||||
long millis = timeout.toMillis();
|
||||
if (millis > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("timeout exceeds Integer.MAX_VALUE milliseconds");
|
||||
}
|
||||
timeoutMs = (int) millis;
|
||||
}
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_READ_BULK)
|
||||
.setReadBulk(ReadBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllTagAddresses(tagAddresses)
|
||||
.setTimeoutMs(timeoutMs))
|
||||
.build());
|
||||
return reply.getReadBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code Write}.
|
||||
*
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Shared base for the cancellable subscription handles returned by the
|
||||
* async-style server-streaming RPCs ({@code streamEvents}, {@code streamAlarms},
|
||||
* {@code queryActiveAlarms}, {@code watchDeployEvents}).
|
||||
*
|
||||
* <p>All four subscription classes share the same lifecycle and cancellation
|
||||
* contract:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #wrap(StreamObserver)} returns a {@link ClientResponseObserver}
|
||||
* that captures the underlying {@link ClientCallStreamObserver} in
|
||||
* {@code beforeStart}. If {@link #cancel()} was called before the gRPC
|
||||
* call attached, the stream is cancelled eagerly inside
|
||||
* {@code beforeStart} (the Client.Java-014 close-before-beforeStart
|
||||
* fix).</li>
|
||||
* <li>{@link #cancel()} is idempotent. It records the cancellation flag and
|
||||
* forwards {@code cancel(message, cause)} to the underlying stream when
|
||||
* one is attached; otherwise the flag is checked in {@code beforeStart}
|
||||
* once the stream attaches.</li>
|
||||
* <li>{@link #close()} delegates to {@link #cancel()} so the handle can be
|
||||
* used with try-with-resources.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Subclasses supply only the cancel-message string used by {@code cancel()}.
|
||||
* Refactor introduced for Client.Java-036 — the four prior subscription
|
||||
* classes were structural near-clones (~60 lines each).
|
||||
*/
|
||||
abstract class MxGatewayStreamSubscription<TRequest, TResponse> implements AutoCloseable {
|
||||
private final AtomicReference<ClientCallStreamObserver<TRequest>> requestStream = new AtomicReference<>();
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
private final String cancelMessage;
|
||||
|
||||
MxGatewayStreamSubscription(String cancelMessage) {
|
||||
this.cancelMessage = cancelMessage;
|
||||
}
|
||||
|
||||
final ClientResponseObserver<TRequest, TResponse> wrap(StreamObserver<TResponse> observer) {
|
||||
return new ClientResponseObserver<>() {
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<TRequest> stream) {
|
||||
requestStream.set(stream);
|
||||
if (cancelled.get()) {
|
||||
stream.cancel(cancelMessage, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(TResponse value) {
|
||||
observer.onNext(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
observer.onError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
||||
* started; cancellation is recorded and applied as soon as the stream
|
||||
* attaches.
|
||||
*/
|
||||
public final void cancel() {
|
||||
cancelled.set(true);
|
||||
ClientCallStreamObserver<TRequest> stream = requestStream.get();
|
||||
if (stream != null) {
|
||||
stream.cancel(cancelMessage, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void close() {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
+321
@@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.protobuf.Timestamp;
|
||||
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||
@@ -24,6 +26,7 @@ import io.grpc.Server;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||
import io.grpc.inprocess.InProcessServerBuilder;
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
@@ -31,11 +34,20 @@ import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -196,6 +208,27 @@ final class GalaxyRepositoryClientTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseChildrenRejectsRepeatedPageToken() throws Exception {
|
||||
// Queue the same BrowseChildrenReply twice with a non-empty NextPageToken.
|
||||
// The client will request a second page and detect that the token repeats.
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
BrowseChildrenReply repeatedReply = browseReply(
|
||||
List.of(obj(1, "Plant", true)),
|
||||
List.of(true),
|
||||
1L,
|
||||
"1:abc:1");
|
||||
service.replies.add(repeatedReply);
|
||||
service.replies.add(repeatedReply);
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, client::browse);
|
||||
|
||||
assertTrue(error.getMessage().contains("repeated page token"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
||||
DeployEvent first = DeployEvent.newBuilder()
|
||||
@@ -306,6 +339,294 @@ final class GalaxyRepositoryClientTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseNoParentReturnsRoots() throws Exception {
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(1, "Plant", true), obj(2, "Other", false)),
|
||||
List.of(true, false),
|
||||
1L,
|
||||
""));
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<LazyBrowseNode> roots = client.browse();
|
||||
|
||||
assertEquals(2, roots.size());
|
||||
assertEquals("Plant", roots.get(0).getObject().getTagName());
|
||||
assertTrue(roots.get(0).hasChildrenHint());
|
||||
assertFalse(roots.get(0).isExpanded());
|
||||
assertEquals("Other", roots.get(1).getObject().getTagName());
|
||||
assertFalse(roots.get(1).hasChildrenHint());
|
||||
assertFalse(roots.get(1).isExpanded());
|
||||
assertEquals(1, service.calls.size());
|
||||
assertFalse(service.calls.get(0).hasParentGobjectId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseExpandPopulatesChildrenAndMarksExpanded() throws Exception {
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(1, "Plant", true)),
|
||||
List.of(true),
|
||||
1L,
|
||||
""));
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(10, "Line1", false)),
|
||||
List.of(false),
|
||||
1L,
|
||||
""));
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<LazyBrowseNode> roots = client.browse();
|
||||
roots.get(0).expand();
|
||||
|
||||
assertTrue(roots.get(0).isExpanded());
|
||||
assertEquals(1, roots.get(0).getChildren().size());
|
||||
assertEquals("Line1", roots.get(0).getChildren().get(0).getObject().getTagName());
|
||||
assertEquals(2, service.calls.size());
|
||||
assertTrue(service.calls.get(1).hasParentGobjectId());
|
||||
assertEquals(1, service.calls.get(1).getParentGobjectId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseExpandIdempotentNoSecondRpc() throws Exception {
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(1, "Plant", true)),
|
||||
List.of(true),
|
||||
1L,
|
||||
""));
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(10, "Line1", false)),
|
||||
List.of(false),
|
||||
1L,
|
||||
""));
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<LazyBrowseNode> roots = client.browse();
|
||||
roots.get(0).expand();
|
||||
roots.get(0).expand();
|
||||
|
||||
assertEquals(2, service.calls.size());
|
||||
assertEquals(1, roots.get(0).getChildren().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseExpandUnknownParentThrowsGalaxyNotFound() throws Exception {
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(1, "Plant", true)),
|
||||
List.of(true),
|
||||
1L,
|
||||
""));
|
||||
service.errors.add(Status.NOT_FOUND.withDescription("Parent not found").asRuntimeException());
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<LazyBrowseNode> roots = client.browse();
|
||||
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, () -> roots.get(0).expand());
|
||||
assertTrue(
|
||||
error.getMessage().toLowerCase().contains("not found"),
|
||||
"expected message to mention 'not found', got: " + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseExpandMultiPageGathersAllPages() throws Exception {
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
// Roots
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(7, "Plant", true)),
|
||||
List.of(true),
|
||||
1L,
|
||||
""));
|
||||
// First child page with a next token
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(70, "ChildA", false), obj(71, "ChildB", false)),
|
||||
List.of(false, false),
|
||||
1L,
|
||||
"7:abc:2"));
|
||||
// Second child page closes the loop
|
||||
service.replies.add(browseReply(
|
||||
List.of(obj(72, "ChildC", false)),
|
||||
List.of(false),
|
||||
1L,
|
||||
""));
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<LazyBrowseNode> roots = client.browse();
|
||||
roots.get(0).expand();
|
||||
|
||||
assertEquals(3, roots.get(0).getChildren().size());
|
||||
assertEquals(3, service.calls.size());
|
||||
assertEquals("7:abc:2", service.calls.get(2).getPageToken());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseExpandConcurrentCallersOnlyFireOneRpc() throws Exception {
|
||||
// Verifies that concurrent expand() calls coalesce onto a single in-flight
|
||||
// BrowseChildren RPC and that readers (isExpanded/getChildren) are not
|
||||
// blocked for the full RPC duration.
|
||||
BrowseChildrenReply rootsReply = browseReply(
|
||||
List.of(obj(1, "Plant", true)),
|
||||
List.of(true),
|
||||
7L,
|
||||
"");
|
||||
BrowseChildrenReply childrenReply = browseReply(
|
||||
List.of(obj(2, "Mixer_001", false)),
|
||||
List.of(false),
|
||||
7L,
|
||||
"");
|
||||
|
||||
// Gate the child fetch behind a latch so multiple expanders can pile up.
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicInteger childCalls = new AtomicInteger();
|
||||
BrowseChildrenService service = new BrowseChildrenService() {
|
||||
@Override
|
||||
public void browseChildren(
|
||||
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
|
||||
calls.add(request);
|
||||
BrowseChildrenReply reply;
|
||||
if (!request.hasParentGobjectId()) {
|
||||
reply = rootsReply;
|
||||
} else {
|
||||
// Block the leader until the followers have arrived.
|
||||
try {
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS), "release latch never tripped");
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
responseObserver.onError(Status.CANCELLED.asRuntimeException());
|
||||
return;
|
||||
}
|
||||
childCalls.incrementAndGet();
|
||||
reply = childrenReply;
|
||||
}
|
||||
responseObserver.onNext(reply);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<LazyBrowseNode> roots = client.browse();
|
||||
LazyBrowseNode root = roots.get(0);
|
||||
|
||||
int parallelism = 10;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(parallelism);
|
||||
try {
|
||||
CountDownLatch ready = new CountDownLatch(parallelism);
|
||||
List<Future<Void>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < parallelism; i++) {
|
||||
futures.add(pool.submit(() -> {
|
||||
ready.countDown();
|
||||
root.expand();
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
// Wait for all callers to be in flight, then release the leader.
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS), "expander threads did not start");
|
||||
// Readers must not be blocked by an in-flight expand; this should not deadlock
|
||||
// and should return the pre-expand state.
|
||||
assertFalse(root.isExpanded());
|
||||
assertEquals(0, root.getChildren().size());
|
||||
release.countDown();
|
||||
|
||||
for (Future<Void> f : futures) {
|
||||
f.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
assertTrue(root.isExpanded());
|
||||
assertEquals(1, root.getChildren().size());
|
||||
// Exactly one expand RPC was issued even though many callers raced.
|
||||
assertEquals(1, childCalls.get());
|
||||
// 1 roots fetch + exactly 1 expand fetch.
|
||||
assertEquals(2, service.calls.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseWithFilterForwardsToRequest() throws Exception {
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
// Default reply is empty; only the request shape matters here.
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
client.browse(BrowseChildrenOptions.builder()
|
||||
.tagNameGlob("Mixer*")
|
||||
.alarmBearingOnly(true)
|
||||
.build());
|
||||
}
|
||||
|
||||
assertEquals(1, service.calls.size());
|
||||
BrowseChildrenRequest request = service.calls.get(0);
|
||||
assertEquals("Mixer*", request.getTagNameGlob());
|
||||
assertTrue(request.getAlarmBearingOnly());
|
||||
}
|
||||
|
||||
private static GalaxyObject obj(int id, String tag, boolean isArea) {
|
||||
return GalaxyObject.newBuilder()
|
||||
.setGobjectId(id)
|
||||
.setTagName(tag)
|
||||
.setBrowseName(tag)
|
||||
.setIsArea(isArea)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static BrowseChildrenReply browseReply(
|
||||
List<GalaxyObject> children,
|
||||
List<Boolean> childHasChildren,
|
||||
long cacheSequence,
|
||||
String nextPageToken) {
|
||||
BrowseChildrenReply.Builder b = BrowseChildrenReply.newBuilder()
|
||||
.setTotalChildCount(children.size())
|
||||
.setCacheSequence(cacheSequence)
|
||||
.setNextPageToken(nextPageToken);
|
||||
b.addAllChildren(children);
|
||||
b.addAllChildHasChildren(childHasChildren);
|
||||
return b.build();
|
||||
}
|
||||
|
||||
private static class BrowseChildrenService extends TestService {
|
||||
final List<BrowseChildrenRequest> calls =
|
||||
Collections.synchronizedList(new CopyOnWriteArrayList<>());
|
||||
final Queue<BrowseChildrenReply> replies = new ArrayDeque<>();
|
||||
final Queue<Throwable> errors = new ArrayDeque<>();
|
||||
|
||||
@Override
|
||||
public void browseChildren(
|
||||
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
|
||||
calls.add(request);
|
||||
BrowseChildrenReply reply;
|
||||
Throwable err;
|
||||
synchronized (this) {
|
||||
// Prefer queued replies first; once they're exhausted, fall through to any
|
||||
// queued error. This matches the .NET fake's ordering used by parity tests.
|
||||
reply = replies.poll();
|
||||
err = reply == null ? errors.poll() : null;
|
||||
}
|
||||
if (err != null) {
|
||||
responseObserver.onError(err);
|
||||
return;
|
||||
}
|
||||
if (reply == null) {
|
||||
reply = BrowseChildrenReply.getDefaultInstance();
|
||||
}
|
||||
responseObserver.onNext(reply);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
|
||||
@Override
|
||||
public void testConnection(
|
||||
|
||||
+98
@@ -27,7 +27,10 @@ import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
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.BulkSubscribeReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||
@@ -41,6 +44,7 @@ import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -268,6 +272,100 @@ final class MxGatewayClientSessionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages() throws Exception {
|
||||
AtomicReference<StreamAlarmsRequest> streamRequest = new AtomicReference<>();
|
||||
CountDownLatch serverCancelled = new CountDownLatch(1);
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> responseObserver) {
|
||||
streamRequest.set(request);
|
||||
ServerCallStreamObserver<AlarmFeedMessage> server =
|
||||
(ServerCallStreamObserver<AlarmFeedMessage>) responseObserver;
|
||||
server.setOnCancelHandler(serverCancelled::countDown);
|
||||
// Active-alarm snapshot, snapshot-complete sentinel, then a
|
||||
// transition — mirrors the shape of a real alarm feed open.
|
||||
server.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||
.setSeverity(700))
|
||||
.build());
|
||||
server.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
|
||||
server.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setTransition(OnAlarmTransitionEvent.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||
.setSeverity(700))
|
||||
.build());
|
||||
// Note: we deliberately do NOT call onCompleted() so the call
|
||||
// remains open for the cancellation assertion below.
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
java.util.List<AlarmFeedMessage> received = new java.util.ArrayList<>();
|
||||
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||
CountDownLatch threeReceived = new CountDownLatch(3);
|
||||
|
||||
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
||||
.setAlarmFilterPrefix("Tank01")
|
||||
.build();
|
||||
|
||||
MxGatewayAlarmFeedSubscription subscription = client.streamAlarms(
|
||||
request,
|
||||
new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(AlarmFeedMessage value) {
|
||||
received.add(value);
|
||||
threeReceived.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
errorRef.set(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(threeReceived.await(5, TimeUnit.SECONDS),
|
||||
"expected three alarm feed messages within 5s");
|
||||
|
||||
// The request shape (filter prefix in particular) must reach the
|
||||
// server — proves MxGatewayClient.streamAlarms calls the production
|
||||
// subscription.wrap(observer) glue and not a CLI override.
|
||||
assertNotNull(streamRequest.get());
|
||||
assertEquals("Tank01", streamRequest.get().getAlarmFilterPrefix());
|
||||
|
||||
// Order and payload-case must be preserved (the wrapping observer
|
||||
// is just a pass-through).
|
||||
assertEquals(3, received.size());
|
||||
assertEquals(AlarmFeedMessage.PayloadCase.ACTIVE_ALARM, received.get(0).getPayloadCase());
|
||||
assertEquals(
|
||||
"Tank01.Level.HiHi",
|
||||
received.get(0).getActiveAlarm().getAlarmFullReference());
|
||||
assertEquals(AlarmFeedMessage.PayloadCase.SNAPSHOT_COMPLETE, received.get(1).getPayloadCase());
|
||||
assertEquals(AlarmFeedMessage.PayloadCase.TRANSITION, received.get(2).getPayloadCase());
|
||||
assertEquals(
|
||||
AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE,
|
||||
received.get(2).getTransition().getTransitionKind());
|
||||
|
||||
// No error expected before cancellation — proves the wrapping
|
||||
// observer forwarded only data, not a synthetic error.
|
||||
assertNull(errorRef.get(), "no error expected before cancellation");
|
||||
|
||||
// Cancellation must propagate to the underlying gRPC call.
|
||||
subscription.cancel();
|
||||
assertTrue(serverCancelled.await(5, TimeUnit.SECONDS),
|
||||
"server should observe RPC cancellation after subscription.cancel()");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandFailureKeepsRawReply() throws Exception {
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.Server;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.net.ssl.SSLException;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Verifies that the Java client connects to a Netty TLS server with a
|
||||
* self-signed certificate when no CA is pinned (lenient default), and that
|
||||
* setting {@code requireCertificateValidation(true)} causes a TLS failure.
|
||||
*
|
||||
* <p>A self-signed certificate is generated using {@code keytool} (always
|
||||
* available in the JDK) to avoid dependencies on internal JDK APIs or
|
||||
* BouncyCastle, and so the test works on all JDK versions used by the project.
|
||||
*/
|
||||
final class MxGatewayClientTlsTests {
|
||||
|
||||
private Server server;
|
||||
private int port;
|
||||
private File certPemFile;
|
||||
private File keyPemFile;
|
||||
private File keystoreFile;
|
||||
|
||||
@BeforeEach
|
||||
void startTlsServer() throws Exception {
|
||||
keystoreFile = File.createTempFile("gw-test-ks", ".p12");
|
||||
certPemFile = File.createTempFile("gw-test-cert", ".pem");
|
||||
keyPemFile = File.createTempFile("gw-test-key", ".pem");
|
||||
|
||||
// keytool refuses to write to a pre-existing (even empty) file; delete it first.
|
||||
keystoreFile.delete();
|
||||
|
||||
// Use keytool to generate a self-signed PKCS12 keystore.
|
||||
String keytool = ProcessHandle.current().info().command()
|
||||
.map(cmd -> cmd.replace("java", "keytool"))
|
||||
.orElse("keytool");
|
||||
// Fall back to just "keytool" on PATH if the resolved path doesn't exist.
|
||||
if (!new File(keytool).exists()) {
|
||||
keytool = "keytool";
|
||||
}
|
||||
Process p = new ProcessBuilder(
|
||||
keytool,
|
||||
"-genkeypair",
|
||||
"-alias", "server",
|
||||
"-keyalg", "RSA",
|
||||
"-keysize", "2048",
|
||||
"-sigalg", "SHA256withRSA",
|
||||
"-validity", "1",
|
||||
"-dname", "CN=localhost",
|
||||
"-storetype", "PKCS12",
|
||||
"-storepass", "changeit",
|
||||
"-keypass", "changeit",
|
||||
"-keystore", keystoreFile.getAbsolutePath())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
int exit = p.waitFor();
|
||||
if (exit != 0) {
|
||||
String out = new String(p.getInputStream().readAllBytes());
|
||||
throw new IllegalStateException("keytool failed (exit " + exit + "): " + out);
|
||||
}
|
||||
|
||||
// Export cert and private key from the PKCS12 keystore to PEM files.
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
try (var is = Files.newInputStream(keystoreFile.toPath())) {
|
||||
ks.load(is, "changeit".toCharArray());
|
||||
}
|
||||
X509Certificate cert = (X509Certificate) ks.getCertificate("server");
|
||||
PrivateKey privateKey = (PrivateKey) ks.getKey("server", "changeit".toCharArray());
|
||||
|
||||
try (FileOutputStream out = new FileOutputStream(certPemFile)) {
|
||||
out.write("-----BEGIN CERTIFICATE-----\n".getBytes());
|
||||
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(cert.getEncoded()));
|
||||
out.write("\n-----END CERTIFICATE-----\n".getBytes());
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(keyPemFile)) {
|
||||
out.write("-----BEGIN PRIVATE KEY-----\n".getBytes());
|
||||
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(privateKey.getEncoded()));
|
||||
out.write("\n-----END PRIVATE KEY-----\n".getBytes());
|
||||
}
|
||||
|
||||
server = NettyServerBuilder
|
||||
.forAddress(new InetSocketAddress("127.0.0.1", 0))
|
||||
.sslContext(GrpcSslContexts.forServer(certPemFile, keyPemFile).build())
|
||||
.addService(new MinimalGatewayService())
|
||||
.build()
|
||||
.start();
|
||||
port = server.getPort();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopTlsServer() throws InterruptedException {
|
||||
if (server != null) {
|
||||
server.shutdown();
|
||||
server.awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
if (certPemFile != null) {
|
||||
certPemFile.delete();
|
||||
}
|
||||
if (keyPemFile != null) {
|
||||
keyPemFile.delete();
|
||||
}
|
||||
if (keystoreFile != null) {
|
||||
keystoreFile.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectsToSelfSignedServer_WhenRequireCertificateValidationIsFalse() throws SSLException {
|
||||
// Default options — requireCertificateValidation defaults to false.
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("127.0.0.1:" + port)
|
||||
.apiKey("test-key")
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.callTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
|
||||
try {
|
||||
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
|
||||
MxAccessGatewayGrpc.newBlockingStub(channel);
|
||||
OpenSessionReply reply = stub.openSession(
|
||||
OpenSessionRequest.newBuilder()
|
||||
.setClientSessionName("tls-test")
|
||||
.build());
|
||||
assertTrue(reply.getProtocolStatus().getCode()
|
||||
== ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK);
|
||||
} finally {
|
||||
channel.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsToConnect_WhenRequireCertificateValidationIsTrue() throws SSLException {
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("127.0.0.1:" + port)
|
||||
.apiKey("test-key")
|
||||
.requireCertificateValidation(true)
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.callTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
|
||||
try {
|
||||
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
|
||||
MxAccessGatewayGrpc.newBlockingStub(channel);
|
||||
assertThrows(StatusRuntimeException.class, () ->
|
||||
stub.openSession(OpenSessionRequest.newBuilder()
|
||||
.setClientSessionName("tls-strict-test")
|
||||
.build()));
|
||||
} finally {
|
||||
channel.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal gateway stub that succeeds any OpenSession call. */
|
||||
private static final class MinimalGatewayService
|
||||
extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
||||
@Override
|
||||
public void openSession(
|
||||
OpenSessionRequest request,
|
||||
StreamObserver<OpenSessionReply> responseObserver) {
|
||||
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||
.setSessionId("tls-test-session")
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||
.build())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Lifecycle / cancellation contract tests applied uniformly to each of the
|
||||
* four subscription classes that extend {@link MxGatewayStreamSubscription}.
|
||||
*
|
||||
* <p>Locks in the Client.Java-036 refactor: every subclass must exhibit the
|
||||
* same behaviour for (a) cancel-before-beforeStart eagerly cancelling the
|
||||
* stream once it attaches, (b) cancel-after-beforeStart forwarding directly
|
||||
* to the stream, (c) the cancel message matching the subclass's documented
|
||||
* value, (d) {@code close()} delegating to {@code cancel()}, and (e) the
|
||||
* wrapping observer forwarding {@code onNext}/{@code onError}/{@code onCompleted}
|
||||
* to the caller's observer.
|
||||
*/
|
||||
final class MxGatewayStreamSubscriptionContractTests {
|
||||
|
||||
@Test
|
||||
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_eventSubscription() {
|
||||
runCancelBeforeBeforeStartTest(new MxGatewayEventSubscription(), "client cancelled event stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_alarmFeedSubscription() {
|
||||
runCancelBeforeBeforeStartTest(
|
||||
new MxGatewayAlarmFeedSubscription(), "client cancelled alarm feed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_activeAlarmsSubscription() {
|
||||
runCancelBeforeBeforeStartTest(
|
||||
new MxGatewayActiveAlarmsSubscription(), "client cancelled active-alarms query");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_deployEventSubscription() {
|
||||
runCancelBeforeBeforeStartTest(
|
||||
new DeployEventSubscription(), "client cancelled deploy event stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelAfterBeforeStartForwardsToStream_eventSubscription() {
|
||||
runCancelAfterBeforeStartTest(new MxGatewayEventSubscription(), "client cancelled event stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelAfterBeforeStartForwardsToStream_alarmFeedSubscription() {
|
||||
runCancelAfterBeforeStartTest(
|
||||
new MxGatewayAlarmFeedSubscription(), "client cancelled alarm feed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelAfterBeforeStartForwardsToStream_activeAlarmsSubscription() {
|
||||
runCancelAfterBeforeStartTest(
|
||||
new MxGatewayActiveAlarmsSubscription(), "client cancelled active-alarms query");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelAfterBeforeStartForwardsToStream_deployEventSubscription() {
|
||||
runCancelAfterBeforeStartTest(
|
||||
new DeployEventSubscription(), "client cancelled deploy event stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeDelegatesToCancel_eventSubscription() {
|
||||
runCloseDelegatesToCancelTest(new MxGatewayEventSubscription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeDelegatesToCancel_alarmFeedSubscription() {
|
||||
runCloseDelegatesToCancelTest(new MxGatewayAlarmFeedSubscription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeDelegatesToCancel_activeAlarmsSubscription() {
|
||||
runCloseDelegatesToCancelTest(new MxGatewayActiveAlarmsSubscription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeDelegatesToCancel_deployEventSubscription() {
|
||||
runCloseDelegatesToCancelTest(new DeployEventSubscription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrappedObserverForwardsOnNextOnErrorOnCompleted_eventSubscription() {
|
||||
MxEvent event = MxEvent.newBuilder().setWorkerSequence(7L).build();
|
||||
runForwardingTest(new MxGatewayEventSubscription(), event);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrappedObserverForwardsOnNextOnErrorOnCompleted_alarmFeedSubscription() {
|
||||
AlarmFeedMessage msg = AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build();
|
||||
runForwardingTest(new MxGatewayAlarmFeedSubscription(), msg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrappedObserverForwardsOnNextOnErrorOnCompleted_activeAlarmsSubscription() {
|
||||
ActiveAlarmSnapshot snap = ActiveAlarmSnapshot.newBuilder()
|
||||
.setAlarmFullReference("ref")
|
||||
.setSeverity(500)
|
||||
.build();
|
||||
runForwardingTest(new MxGatewayActiveAlarmsSubscription(), snap);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrappedObserverForwardsOnNextOnErrorOnCompleted_deployEventSubscription() {
|
||||
DeployEvent ev = DeployEvent.newBuilder().setSequence(1L).build();
|
||||
runForwardingTest(new DeployEventSubscription(), ev);
|
||||
}
|
||||
|
||||
private static <Req, Resp> void runCancelBeforeBeforeStartTest(
|
||||
MxGatewayStreamSubscription<Req, Resp> subscription, String expectedMessage) {
|
||||
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(new NoopObserver<>());
|
||||
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||
|
||||
subscription.cancel();
|
||||
wrapped.beforeStart(stream);
|
||||
|
||||
assertTrue(stream.cancelled, "stream should have been cancelled by beforeStart after prior cancel()");
|
||||
assertEquals(expectedMessage, stream.cancelMessage);
|
||||
}
|
||||
|
||||
private static <Req, Resp> void runCancelAfterBeforeStartTest(
|
||||
MxGatewayStreamSubscription<Req, Resp> subscription, String expectedMessage) {
|
||||
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(new NoopObserver<>());
|
||||
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||
|
||||
wrapped.beforeStart(stream);
|
||||
assertFalse(stream.cancelled, "stream should not be cancelled before cancel() is called");
|
||||
subscription.cancel();
|
||||
|
||||
assertTrue(stream.cancelled, "stream should have been cancelled by direct cancel()");
|
||||
assertEquals(expectedMessage, stream.cancelMessage);
|
||||
}
|
||||
|
||||
private static <Req, Resp> void runCloseDelegatesToCancelTest(
|
||||
MxGatewayStreamSubscription<Req, Resp> subscription) {
|
||||
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(new NoopObserver<>());
|
||||
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||
|
||||
wrapped.beforeStart(stream);
|
||||
subscription.close();
|
||||
|
||||
assertTrue(stream.cancelled, "close() should delegate to cancel()");
|
||||
}
|
||||
|
||||
private static <Req, Resp> void runForwardingTest(
|
||||
MxGatewayStreamSubscription<Req, Resp> subscription, Resp value) {
|
||||
List<Resp> received = new ArrayList<>();
|
||||
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||
AtomicReference<Boolean> completed = new AtomicReference<>(false);
|
||||
|
||||
StreamObserver<Resp> caller = new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(Resp v) {
|
||||
received.add(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
errorRef.set(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
completed.set(true);
|
||||
}
|
||||
};
|
||||
|
||||
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(caller);
|
||||
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||
wrapped.beforeStart(stream);
|
||||
|
||||
wrapped.onNext(value);
|
||||
IllegalStateException boom = new IllegalStateException("boom");
|
||||
wrapped.onError(boom);
|
||||
wrapped.onCompleted();
|
||||
|
||||
assertEquals(1, received.size());
|
||||
assertEquals(value, received.get(0));
|
||||
assertNotNull(errorRef.get());
|
||||
assertEquals(boom, errorRef.get());
|
||||
assertTrue(completed.get());
|
||||
}
|
||||
|
||||
private static final class NoopObserver<T> implements StreamObserver<T> {
|
||||
@Override
|
||||
public void onNext(T value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingClientCallStreamObserver<T> extends ClientCallStreamObserver<T> {
|
||||
boolean cancelled;
|
||||
String cancelMessage;
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelled = true;
|
||||
cancelMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(T value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guarantee that the parameter types still match the
|
||||
// generic bounds — catches a regression where a subclass changes its
|
||||
// request/response types out from under the shared base.
|
||||
@SuppressWarnings("unused")
|
||||
private static void typeBoundsCheck() {
|
||||
MxGatewayStreamSubscription<StreamEventsRequest, MxEvent> a = new MxGatewayEventSubscription();
|
||||
MxGatewayStreamSubscription<StreamAlarmsRequest, AlarmFeedMessage> b = new MxGatewayAlarmFeedSubscription();
|
||||
MxGatewayStreamSubscription<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> c =
|
||||
new MxGatewayActiveAlarmsSubscription();
|
||||
MxGatewayStreamSubscription<WatchDeployEventsRequest, DeployEvent> d = new DeployEventSubscription();
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,28 @@ Support:
|
||||
- TLS channel with default roots,
|
||||
- custom root certificate file.
|
||||
|
||||
### Trust posture (trust-on-first-use)
|
||||
|
||||
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||
PKI). grpc-python exposes no per-channel skip-verify hook, so the client cannot
|
||||
"accept any certificate" the way the other clients do. Instead, when the channel
|
||||
is not plaintext and neither `ca_file` nor `require_certificate_validation` is
|
||||
set, the TLS default is **trust-on-first-use**: the client fetches the server's
|
||||
presented certificate once via `ssl.get_server_certificate` (an unverified
|
||||
probe), pins it as the channel's only trust root, and — because the generated
|
||||
certificate always carries a `localhost` SAN — defaults
|
||||
`grpc.ssl_target_name_override` to `localhost` when no `server_name_override` was
|
||||
supplied (tolerating dial-by-IP or a hostname mismatch). A failed probe is
|
||||
surfaced as a transport error naming the endpoint.
|
||||
|
||||
To verify the gateway instead:
|
||||
|
||||
- set `ca_file` to verify against a specific CA, or
|
||||
- set `require_certificate_validation=True` to verify against the system trust
|
||||
roots.
|
||||
|
||||
Both bypass the TOFU path.
|
||||
|
||||
## Streaming
|
||||
|
||||
Expose `stream_events` as an async iterator. Canceling the task should cancel
|
||||
|
||||
@@ -95,6 +95,13 @@ async with await GatewayClient.connect(
|
||||
events available for parity tests. `Session` helpers call the method-specific
|
||||
MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
|
||||
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
|
||||
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
|
||||
messages from the gateway's central monitor), and
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
|
||||
and ack target). Cancel the surrounding task or `aclose()` the iterator to
|
||||
terminate the stream.
|
||||
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
|
||||
@@ -131,6 +138,49 @@ The methods return native Python types (`bool`, `datetime | None`, and a
|
||||
into the hierarchy without learning the underlying stub class. The
|
||||
service requires the `metadata:read` scope on the API key.
|
||||
|
||||
### Browsing lazily
|
||||
|
||||
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
|
||||
time instead of loading the full hierarchy with `discover_hierarchy`. Pass an
|
||||
empty request for root objects; subsequent calls set `parent_gobject_id`,
|
||||
`parent_tag_name`, or `parent_contained_path`. Filter fields match
|
||||
`DiscoverHierarchy`. Each response pairs `children` with `child_has_children` so
|
||||
you know which nodes to expand. See
|
||||
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||
request and filter semantics.
|
||||
|
||||
```python
|
||||
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb2
|
||||
|
||||
reply = await galaxy.browse_children(galaxy_pb2.BrowseChildrenRequest())
|
||||
for child, has_children in zip(reply.children, reply.child_has_children):
|
||||
print(child.tag_name, "expand=" + str(has_children))
|
||||
```
|
||||
|
||||
#### High-level walker
|
||||
|
||||
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||
sibling pagination and the `child_has_children` hint for you:
|
||||
|
||||
```python
|
||||
async with await GalaxyRepositoryClient.connect(
|
||||
endpoint="localhost:5000",
|
||||
api_key="<gateway-api-key>",
|
||||
plaintext=True,
|
||||
) as galaxy:
|
||||
roots = await galaxy.browse()
|
||||
for root in roots:
|
||||
if root.has_children_hint:
|
||||
await root.expand()
|
||||
for child in root.children:
|
||||
kind = "has children" if child.has_children_hint else "leaf"
|
||||
print(f"{child.object.tag_name} ({kind})")
|
||||
```
|
||||
|
||||
`expand` is idempotent — calling it twice fires only one RPC,
|
||||
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||
`browse` again from the root.
|
||||
|
||||
### Watching deploy events
|
||||
|
||||
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
||||
@@ -180,6 +230,21 @@ The client supports plaintext channels for local development, TLS with system
|
||||
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
||||
API keys are redacted from option repr output and CLI error output.
|
||||
|
||||
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||
grpc-python has no per-channel skip-verify, so the lenient TLS default is
|
||||
**trust-on-first-use**: with no `ca_file` and `require_certificate_validation`
|
||||
left `False`, the client fetches the gateway's presented certificate once
|
||||
(unverified) and pins it for the channel, defaulting the SNI/target-name override
|
||||
to `localhost` (the generated certificate always carries a `localhost` SAN) when
|
||||
none was supplied. To verify instead, pass `ca_file` to verify against a specific
|
||||
CA, or set `require_certificate_validation=True` to verify against the system
|
||||
trust roots. The strict posture is reachable through every documented entry
|
||||
point: the `require_certificate_validation=True` keyword on
|
||||
`GatewayClient.connect(...)` / `GalaxyRepositoryClient.connect(...)`, the
|
||||
`ClientOptions(require_certificate_validation=True)` struct, and the
|
||||
`--require-certificate-validation` CLI flag. See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI emits deterministic JSON for automation:
|
||||
@@ -191,6 +256,8 @@ mxgw-py register --session-id <id> --client-name python-client --json
|
||||
mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --json
|
||||
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
|
||||
mxgw-py stream-events --session-id <id> --max-events 1 --json
|
||||
mxgw-py stream-alarms --max-messages 1 --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
|
||||
```
|
||||
|
||||
@@ -204,6 +271,13 @@ Use TLS options for a secured gateway:
|
||||
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Object.Attribute --json
|
||||
```
|
||||
|
||||
To force certificate validation against the system trust store instead of the
|
||||
lenient trust-on-first-use default, add `--require-certificate-validation`:
|
||||
|
||||
```powershell
|
||||
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --require-certificate-validation --api-key-env MXGATEWAY_API_KEY --item Object.Attribute --json
|
||||
```
|
||||
|
||||
## Integration Checks
|
||||
|
||||
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||
@@ -216,6 +290,19 @@ $env:MXGATEWAY_TEST_ITEM = 'Object.Attribute'
|
||||
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
```
|
||||
|
||||
## Installing from the Gitea PyPI Feed
|
||||
|
||||
The client publishes to the internal Gitea PyPI feed:
|
||||
|
||||
````bash
|
||||
pip install \
|
||||
--index-url https://gitea.dohertylan.com/api/packages/dohertj2/pypi/simple/ \
|
||||
zb-mom-ww-mxaccess-gateway-client
|
||||
````
|
||||
|
||||
If you need authentication (private feed), use `--extra-index-url` and either
|
||||
a `~/.netrc` entry or `PIP_INDEX_URL=https://<user>:<token>@gitea.dohertylan.com/...`.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
# setuptools >=77 emits core-metadata 2.4 (PEP 639 License-Expression), which the
|
||||
# Gitea PyPI feed does not yet accept; cap below that so the dist stays <=2.3.
|
||||
requires = ["setuptools>=69,<77", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "zb-mom-ww-mxaccess-gateway-client"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "Async Python client scaffold for MXAccess Gateway."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -13,12 +15,34 @@ dependencies = [
|
||||
"grpcio>=1.80,<2",
|
||||
"protobuf>=6.33,<7",
|
||||
]
|
||||
authors = [
|
||||
{ name = "Joseph Doherty" },
|
||||
]
|
||||
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||
classifiers = [
|
||||
"License :: Other/Proprietary License",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: System :: Distributed Computing",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"grpcio-tools>=1.80,<2",
|
||||
"pytest>=9,<10",
|
||||
"pytest-asyncio>=1.3,<2",
|
||||
"build>=1.2,<2",
|
||||
"twine>=5,<6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -31,3 +55,6 @@ where = ["src"]
|
||||
addopts = "-ra"
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"tls: loopback TLS tests, opt-in via MXGATEWAY_RUN_TLS_TESTS=1",
|
||||
]
|
||||
|
||||
@@ -40,6 +40,7 @@ class GatewayClient:
|
||||
api_key: str | None = None,
|
||||
plaintext: bool = False,
|
||||
ca_file: str | None = None,
|
||||
require_certificate_validation: bool = False,
|
||||
server_name_override: str | None = None,
|
||||
stub: Any | None = None,
|
||||
) -> "GatewayClient":
|
||||
@@ -50,13 +51,16 @@ class GatewayClient:
|
||||
api_key=api_key,
|
||||
plaintext=plaintext,
|
||||
ca_file=ca_file,
|
||||
require_certificate_validation=require_certificate_validation,
|
||||
server_name_override=server_name_override,
|
||||
)
|
||||
|
||||
if stub is not None:
|
||||
return cls(options=resolved, stub=stub)
|
||||
|
||||
channel = create_channel(resolved)
|
||||
# create_channel may perform a blocking TLS certificate probe (TOFU
|
||||
# default); run it off the event loop so connect never freezes the loop.
|
||||
channel = await asyncio.to_thread(create_channel, resolved)
|
||||
return cls(
|
||||
options=resolved,
|
||||
stub=pb_grpc.MxAccessGatewayStub(channel),
|
||||
@@ -172,6 +176,28 @@ class GatewayClient:
|
||||
call = self.raw_stub.QueryActiveAlarms(request, **kwargs)
|
||||
return _canceling_active_alarms_iterator(call)
|
||||
|
||||
def stream_alarms(
|
||||
self,
|
||||
request: pb.StreamAlarmsRequest,
|
||||
*,
|
||||
metadata: Sequence[tuple[str, str]] | None = None,
|
||||
) -> AsyncIterator[pb.AlarmFeedMessage]:
|
||||
"""Attach 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``, then a ``transition`` 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
|
||||
(``request.alarm_filter_prefix``).
|
||||
"""
|
||||
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
|
||||
if self.options.stream_timeout is not None:
|
||||
kwargs["timeout"] = self.options.stream_timeout
|
||||
call = self.raw_stub.StreamAlarms(request, **kwargs)
|
||||
return _canceling_alarm_feed_iterator(call)
|
||||
|
||||
async def _unary(
|
||||
self,
|
||||
operation: str,
|
||||
@@ -223,3 +249,15 @@ async def _canceling_active_alarms_iterator(call: Any) -> AsyncIterator[pb.Activ
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
|
||||
|
||||
async def _canceling_alarm_feed_iterator(call: Any) -> AsyncIterator[pb.AlarmFeedMessage]:
|
||||
try:
|
||||
async for message in call:
|
||||
yield message
|
||||
except grpc.RpcError as error:
|
||||
raise map_rpc_error("stream alarms", error) from error
|
||||
finally:
|
||||
cancel = getattr(call, "cancel", None)
|
||||
if cancel is not None:
|
||||
cancel()
|
||||
|
||||
@@ -21,9 +21,10 @@ from .auth import merge_metadata
|
||||
from .errors import MxGatewayError, map_rpc_error
|
||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
from .options import ClientOptions, create_channel
|
||||
from .options import BrowseChildrenOptions, ClientOptions, create_channel
|
||||
|
||||
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
|
||||
_BROWSE_CHILDREN_PAGE_SIZE = 500
|
||||
|
||||
|
||||
class GalaxyRepositoryClient:
|
||||
@@ -51,6 +52,7 @@ class GalaxyRepositoryClient:
|
||||
api_key: str | None = None,
|
||||
plaintext: bool = False,
|
||||
ca_file: str | None = None,
|
||||
require_certificate_validation: bool = False,
|
||||
server_name_override: str | None = None,
|
||||
stub: Any | None = None,
|
||||
) -> "GalaxyRepositoryClient":
|
||||
@@ -61,13 +63,16 @@ class GalaxyRepositoryClient:
|
||||
api_key=api_key,
|
||||
plaintext=plaintext,
|
||||
ca_file=ca_file,
|
||||
require_certificate_validation=require_certificate_validation,
|
||||
server_name_override=server_name_override,
|
||||
)
|
||||
|
||||
if stub is not None:
|
||||
return cls(options=resolved, stub=stub)
|
||||
|
||||
channel = create_channel(resolved)
|
||||
# create_channel may perform a blocking TLS certificate probe (TOFU
|
||||
# default); run it off the event loop so connect never freezes the loop.
|
||||
channel = await asyncio.to_thread(create_channel, resolved)
|
||||
return cls(
|
||||
options=resolved,
|
||||
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
|
||||
@@ -139,6 +144,89 @@ class GalaxyRepositoryClient:
|
||||
)
|
||||
seen_page_tokens.add(page_token)
|
||||
|
||||
async def browse_children_raw(
|
||||
self, request: galaxy_pb.BrowseChildrenRequest
|
||||
) -> galaxy_pb.BrowseChildrenReply:
|
||||
"""Issue one BrowseChildren RPC and return the raw reply.
|
||||
|
||||
Lower-level escape hatch for callers that need direct page-token control
|
||||
or do not want LazyBrowseNode wrapping. Most callers should use
|
||||
:py:meth:`browse` and :py:meth:`LazyBrowseNode.expand` instead.
|
||||
"""
|
||||
|
||||
return await self._unary(
|
||||
"browse children",
|
||||
self.raw_stub.BrowseChildren,
|
||||
request,
|
||||
)
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
options: BrowseChildrenOptions | None = None,
|
||||
) -> list["LazyBrowseNode"]:
|
||||
"""Return the root browse nodes for lazy hierarchy traversal.
|
||||
|
||||
Each returned ``LazyBrowseNode`` wraps a Galaxy object whose direct
|
||||
children can be loaded on demand by ``await node.expand()``.
|
||||
"""
|
||||
|
||||
effective = options or BrowseChildrenOptions()
|
||||
return [
|
||||
node
|
||||
async for node in self._iter_browse_children(
|
||||
parent_gobject_id=None,
|
||||
options=effective,
|
||||
)
|
||||
]
|
||||
|
||||
async def _iter_browse_children(
|
||||
self,
|
||||
*,
|
||||
parent_gobject_id: int | None,
|
||||
options: BrowseChildrenOptions,
|
||||
) -> AsyncIterator["LazyBrowseNode"]:
|
||||
page_token = ""
|
||||
seen_page_tokens: set[str] = set()
|
||||
while True:
|
||||
request = galaxy_pb.BrowseChildrenRequest(
|
||||
page_size=_BROWSE_CHILDREN_PAGE_SIZE,
|
||||
page_token=page_token,
|
||||
alarm_bearing_only=options.alarm_bearing_only,
|
||||
historized_only=options.historized_only,
|
||||
)
|
||||
if parent_gobject_id is not None:
|
||||
request.parent_gobject_id = parent_gobject_id
|
||||
if options.category_ids:
|
||||
request.category_ids.extend(options.category_ids)
|
||||
if options.template_chain_contains:
|
||||
request.template_chain_contains.extend(options.template_chain_contains)
|
||||
if options.tag_name_glob:
|
||||
request.tag_name_glob = options.tag_name_glob
|
||||
if options.include_attributes is not None:
|
||||
request.include_attributes = options.include_attributes
|
||||
|
||||
reply = await self._unary(
|
||||
"browse children",
|
||||
self.raw_stub.BrowseChildren,
|
||||
request,
|
||||
)
|
||||
|
||||
for index, obj in enumerate(reply.children):
|
||||
hint = (
|
||||
index < len(reply.child_has_children)
|
||||
and bool(reply.child_has_children[index])
|
||||
)
|
||||
yield LazyBrowseNode(self, obj, hint, options)
|
||||
|
||||
page_token = reply.next_page_token
|
||||
if not page_token:
|
||||
return
|
||||
if page_token in seen_page_tokens:
|
||||
raise MxGatewayError(
|
||||
f"galaxy browse children returned repeated page token {page_token!r}"
|
||||
)
|
||||
seen_page_tokens.add(page_token)
|
||||
|
||||
def watch_deploy_events(
|
||||
self,
|
||||
last_seen_deploy_time: datetime | None = None,
|
||||
@@ -202,6 +290,67 @@ class GalaxyRepositoryClient:
|
||||
raise map_rpc_error(operation, error) from error
|
||||
|
||||
|
||||
class LazyBrowseNode:
|
||||
"""One node in a lazy-loaded Galaxy browse tree.
|
||||
|
||||
Calling ``expand`` once fetches direct children (paginating as needed)
|
||||
and populates ``children``. Subsequent calls are no-ops so callers can
|
||||
drive UI expand toggles without de-duping.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: "GalaxyRepositoryClient",
|
||||
obj: galaxy_pb.GalaxyObject,
|
||||
has_children_hint: bool,
|
||||
options: BrowseChildrenOptions,
|
||||
) -> None:
|
||||
"""Initialize a node bound to its owning client and filter set."""
|
||||
self._client = client
|
||||
self._object = obj
|
||||
self._has_children_hint = has_children_hint
|
||||
self._options = options
|
||||
self._children: list[LazyBrowseNode] = []
|
||||
self._is_expanded = False
|
||||
self._expand_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def object(self) -> galaxy_pb.GalaxyObject:
|
||||
"""Return the underlying ``GalaxyObject`` proto for this node."""
|
||||
return self._object
|
||||
|
||||
@property
|
||||
def has_children_hint(self) -> bool:
|
||||
"""Return the server hint about whether this node has children."""
|
||||
return self._has_children_hint
|
||||
|
||||
@property
|
||||
def children(self) -> list["LazyBrowseNode"]:
|
||||
"""Return a copy of the loaded child nodes (empty until expanded)."""
|
||||
return list(self._children)
|
||||
|
||||
@property
|
||||
def is_expanded(self) -> bool:
|
||||
"""Return whether ``expand`` has already populated ``children``."""
|
||||
return self._is_expanded
|
||||
|
||||
async def expand(self) -> None:
|
||||
"""Fetch direct children of this node; no-op on subsequent calls."""
|
||||
if self._is_expanded:
|
||||
return
|
||||
async with self._expand_lock:
|
||||
if self._is_expanded:
|
||||
return
|
||||
new_children: list[LazyBrowseNode] = []
|
||||
async for child in self._client._iter_browse_children(
|
||||
parent_gobject_id=self._object.gobject_id,
|
||||
options=self._options,
|
||||
):
|
||||
new_children.append(child)
|
||||
self._children.extend(new_children)
|
||||
self._is_expanded = True
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||
try:
|
||||
async for event in call:
|
||||
|
||||
@@ -26,7 +26,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__
|
||||
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\"\xdc\x02\n\x15\x42rowseChildrenRequest\x12\x1b\n\x11parent_gobject_id\x18\x01 \x01(\x05H\x00\x12\x19\n\x0fparent_tag_name\x18\x02 \x01(\tH\x00\x12\x1f\n\x15parent_contained_path\x18\x03 \x01(\tH\x00\x12\x11\n\tpage_size\x18\x04 \x01(\x05\x12\x12\n\npage_token\x18\x05 \x01(\t\x12\x14\n\x0c\x63\x61tegory_ids\x18\x06 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x07 \x03(\t\x12\x15\n\rtag_name_glob\x18\x08 \x01(\t\x12\x1f\n\x12include_attributes\x18\t \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\n \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0b \x01(\x08\x42\x08\n\x06parentB\x15\n\x13_include_attributes\"\xb3\x01\n\x13\x42rowseChildrenReply\x12\x34\n\x08\x63hildren\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x19\n\x11total_child_count\x18\x03 \x01(\x05\x12\x1a\n\x12\x63hild_has_children\x18\x04 \x03(\x08\x12\x16\n\x0e\x63\x61\x63he_sequence\x18\x05 \x01(\x04\x32\xb6\x04\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n\x0e\x42rowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
@@ -54,6 +54,10 @@ if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals['_GALAXYOBJECT']._serialized_end=1416
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
|
||||
_globals['_GALAXYREPOSITORY']._serialized_start=1718
|
||||
_globals['_GALAXYREPOSITORY']._serialized_end=2178
|
||||
_globals['_BROWSECHILDRENREQUEST']._serialized_start=1718
|
||||
_globals['_BROWSECHILDRENREQUEST']._serialized_end=2066
|
||||
_globals['_BROWSECHILDRENREPLY']._serialized_start=2069
|
||||
_globals['_BROWSECHILDRENREPLY']._serialized_end=2248
|
||||
_globals['_GALAXYREPOSITORY']._serialized_start=2251
|
||||
_globals['_GALAXYREPOSITORY']._serialized_end=2817
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -65,6 +65,11 @@ class GalaxyRepositoryStub(object):
|
||||
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
|
||||
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
|
||||
_registered_method=True)
|
||||
self.BrowseChildren = channel.unary_unary(
|
||||
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
|
||||
request_serializer=galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
|
||||
response_deserializer=galaxy__repository__pb2.BrowseChildrenReply.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class GalaxyRepositoryServicer(object):
|
||||
@@ -111,6 +116,16 @@ class GalaxyRepositoryServicer(object):
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def BrowseChildren(self, request, context):
|
||||
"""Returns the direct children of a parent object (or the root objects when
|
||||
`parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||
one level at a time instead of paging the full hierarchy. Filters mirror
|
||||
DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
@@ -134,6 +149,11 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
|
||||
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
|
||||
),
|
||||
'BrowseChildren': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.BrowseChildren,
|
||||
request_deserializer=galaxy__repository__pb2.BrowseChildrenRequest.FromString,
|
||||
response_serializer=galaxy__repository__pb2.BrowseChildrenReply.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
|
||||
@@ -263,3 +283,30 @@ class GalaxyRepository(object):
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def BrowseChildren(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
|
||||
galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
|
||||
galaxy__repository__pb2.BrowseChildrenReply.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@@ -135,6 +135,9 @@ class MxAccessGatewayServicer(object):
|
||||
reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||
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.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import ssl
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import grpc
|
||||
|
||||
from .auth import REDACTED, ApiKey
|
||||
from .errors import MxGatewayTransportError
|
||||
|
||||
# Fallback bound for the TOFU certificate probe when no call_timeout is set, so a
|
||||
# black-holed host fails fast instead of hanging on the OS default connect timeout.
|
||||
_TOFU_PROBE_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -18,6 +25,7 @@ class ClientOptions:
|
||||
api_key: str | ApiKey | None = None
|
||||
plaintext: bool = False
|
||||
ca_file: str | None = None
|
||||
require_certificate_validation: bool = False
|
||||
server_name_override: str | None = None
|
||||
call_timeout: float | None = 30.0
|
||||
stream_timeout: float | None = None
|
||||
@@ -44,6 +52,7 @@ class ClientOptions:
|
||||
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
|
||||
f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
|
||||
f"ca_file={self.ca_file!r}, "
|
||||
f"require_certificate_validation={self.require_certificate_validation!r}, "
|
||||
f"server_name_override={self.server_name_override!r}, "
|
||||
f"call_timeout={self.call_timeout!r}, "
|
||||
f"stream_timeout={self.stream_timeout!r}, "
|
||||
@@ -51,8 +60,60 @@ class ClientOptions:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowseChildrenOptions:
|
||||
"""Filters and shape options for ``GalaxyRepositoryClient.browse``.
|
||||
|
||||
Mirrors the AND-combined filter set on ``BrowseChildrenRequest`` so a
|
||||
single instance can be re-used across an entire lazy browse session
|
||||
(the filter set is part of the page-token contract).
|
||||
"""
|
||||
|
||||
category_ids: Sequence[int] = field(default_factory=tuple)
|
||||
template_chain_contains: Sequence[str] = field(default_factory=tuple)
|
||||
tag_name_glob: str | None = None
|
||||
include_attributes: bool | None = None
|
||||
alarm_bearing_only: bool = False
|
||||
historized_only: bool = False
|
||||
|
||||
|
||||
def _split_authority(endpoint: str) -> tuple[str, int]:
|
||||
"""Split a gRPC target (optionally scheme-prefixed) into (host, port).
|
||||
|
||||
Handles bracketed IPv6 literals (e.g. ``[::1]:5120`` or bare ``[::1]``),
|
||||
returning the host without brackets so it is safe to pass to
|
||||
``ssl.get_server_certificate``.
|
||||
"""
|
||||
target = endpoint.split("://", 1)[-1]
|
||||
if target.startswith("["):
|
||||
# Bracketed IPv6: "[::1]:5120" or "[::1]"
|
||||
bracket_end = target.find("]")
|
||||
host = target[1:bracket_end] # strip surrounding brackets
|
||||
remainder = target[bracket_end + 1 :] # ":5120" or ""
|
||||
port_str = remainder.lstrip(":")
|
||||
return (host, int(port_str) if port_str else 443)
|
||||
host, sep, port = target.rpartition(":")
|
||||
if not sep:
|
||||
# No colon at all (e.g. a bare hostname "mygateway"): the whole target
|
||||
# is the host; default the port rather than raising on int("mygateway").
|
||||
return (target or "localhost", 443)
|
||||
if not port.isdigit():
|
||||
# A colon with a non-numeric / empty tail (e.g. a trailing ":") is not
|
||||
# an explicit port — keep the left side as the host and default the
|
||||
# port so a typo cannot raise an uncaught ValueError on the TOFU path.
|
||||
return (host or "localhost", 443)
|
||||
return (host or "localhost", int(port))
|
||||
|
||||
|
||||
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||
"""Create a plaintext or TLS `grpc.aio` channel from client options."""
|
||||
"""Create a plaintext or TLS `grpc.aio` channel from client options.
|
||||
|
||||
The TLS default is lenient: grpc-python has no per-channel skip-verify, so
|
||||
the server's presented certificate is fetched once (unverified) and pinned
|
||||
as the channel's only trust root (trust-on-first-use). Set
|
||||
`require_certificate_validation=True` to force system-trust verification, or
|
||||
pass `ca_file` to verify against a specific CA — both bypass the TOFU path.
|
||||
"""
|
||||
|
||||
channel_options: list[tuple[str, str | int]] = [
|
||||
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
||||
@@ -64,11 +125,34 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||
if options.plaintext:
|
||||
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
|
||||
|
||||
root_certificates = None
|
||||
if options.ca_file:
|
||||
root_certificates = Path(options.ca_file).read_bytes()
|
||||
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
||||
elif options.require_certificate_validation:
|
||||
credentials = grpc.ssl_channel_credentials()
|
||||
else:
|
||||
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
|
||||
# server's certificate (unverified) and pin it for this channel (TOFU).
|
||||
# The probe opens a real blocking TCP+TLS socket, so it MUST be bounded —
|
||||
# a black-holed / firewall-drop host would otherwise hang on the OS default
|
||||
# connect timeout (minutes). Bound it by call_timeout (or a short fixed
|
||||
# fallback) so the dial fails fast as a transport error. The async
|
||||
# `connect` classmethods run this off the event loop (asyncio.to_thread).
|
||||
host, port = _split_authority(options.endpoint)
|
||||
probe_timeout = options.call_timeout if options.call_timeout else _TOFU_PROBE_TIMEOUT_SECONDS
|
||||
try:
|
||||
presented = ssl.get_server_certificate((host, port), timeout=probe_timeout)
|
||||
except OSError as error:
|
||||
raise MxGatewayTransportError(
|
||||
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
|
||||
) from error
|
||||
credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
|
||||
# The gateway self-signed cert always carries a "localhost" SAN, so default
|
||||
# the SNI/target-name override to it when none was supplied, tolerating
|
||||
# dial-by-IP or hostname mismatch.
|
||||
if not options.server_name_override:
|
||||
channel_options.append(("grpc.ssl_target_name_override", "localhost"))
|
||||
|
||||
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
||||
return grpc.aio.secure_channel(
|
||||
options.endpoint,
|
||||
credentials,
|
||||
|
||||
@@ -334,6 +334,138 @@ class Session:
|
||||
)
|
||||
return list(reply.unsubscribe_bulk.results)
|
||||
|
||||
async def write_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteBulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteBulk` and return one BulkWriteResult per entry.
|
||||
|
||||
Per-entry MXAccess failures appear as results with ``was_successful = False``
|
||||
and a populated ``error_message`` / ``hresult``; this method does not raise
|
||||
on per-entry failure, mirroring the existing add/advise bulk surface.
|
||||
"""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
||||
write_bulk=pb.WriteBulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_bulk.results)
|
||||
|
||||
async def write2_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.Write2BulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `Write2Bulk` (timestamped) and return per-entry results."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
|
||||
write2_bulk=pb.Write2BulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write2_bulk.results)
|
||||
|
||||
async def write_secured_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteSecuredBulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteSecuredBulk` — credential-sensitive values must not be logged."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||
write_secured_bulk=pb.WriteSecuredBulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_secured_bulk.results)
|
||||
|
||||
async def write_secured2_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteSecured2BulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteSecured2Bulk` (timestamped + verified)."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||
write_secured2_bulk=pb.WriteSecured2BulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_secured2_bulk.results)
|
||||
|
||||
async def read_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
tag_addresses: Sequence[str],
|
||||
*,
|
||||
timeout_ms: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkReadResult]:
|
||||
"""Invoke `ReadBulk` — snapshot the current value of each requested tag.
|
||||
|
||||
MXAccess COM has no synchronous read; the worker returns the cached
|
||||
``OnDataChange`` value for any tag that is already advised (``was_cached =
|
||||
True``) without modifying the existing subscription, and falls back to
|
||||
a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||
otherwise. ``timeout_ms`` bounds the per-tag wait in the snapshot case;
|
||||
pass ``0`` to use the worker default (1000 ms).
|
||||
"""
|
||||
if tag_addresses is None:
|
||||
raise TypeError("tag_addresses is required")
|
||||
_ensure_bulk_size("tag_addresses", len(tag_addresses))
|
||||
if timeout_ms < 0:
|
||||
raise ValueError("timeout_ms must be non-negative")
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
||||
read_bulk=pb.ReadBulkCommand(
|
||||
server_handle=server_handle,
|
||||
tag_addresses=tag_addresses,
|
||||
timeout_ms=timeout_ms,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.read_bulk.results)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
server_handle: int,
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -18,10 +23,14 @@ from zb_mom_ww_mxgateway.client import GatewayClient
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway.options import ClientOptions
|
||||
from zb_mom_ww_mxgateway.values import MxValueInput
|
||||
from zb_mom_ww_mxgateway.values import MxValueInput, to_mx_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_AGGREGATE_EVENTS = 10_000
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
@@ -41,6 +50,114 @@ def version(output_json: bool) -> None:
|
||||
_emit(payload, output_json=output_json, text=f"mxgw-py {__version__}")
|
||||
|
||||
|
||||
@main.command()
|
||||
def batch() -> None:
|
||||
"""Read commands from stdin and execute each, writing output + __MXGW_BATCH_EOR__ after each.
|
||||
|
||||
Each non-empty line of stdin is a complete argument string (no quoting support — the
|
||||
harness never passes whitespace-containing arguments). Lines are split on runs of ASCII
|
||||
whitespace and dispatched through the normal CLI parser. On EOF or an empty line, exit 0.
|
||||
|
||||
Errors do NOT terminate the loop. Each command's output (including any error JSON) is
|
||||
written to stdout followed by a line containing exactly ``__MXGW_BATCH_EOR__``, then
|
||||
stdout is flushed. Error output is formatted as ``{"error": "...", "type": "..."}``.
|
||||
|
||||
Recursive ``batch`` lines are rejected (Client.Python-024) — re-entering the batch
|
||||
dispatcher would silently spawn a nested loop reading from the same exhausted stdin.
|
||||
"""
|
||||
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.rstrip("\n").rstrip("\r")
|
||||
if not line:
|
||||
# Empty line signals clean exit (matches the spec and .NET behaviour).
|
||||
break
|
||||
|
||||
args = line.split()
|
||||
|
||||
# Reject a recursive `batch` line outright: the nested invocation would
|
||||
# read from the already-exhausted stdin (or, depending on harness, the
|
||||
# same stream the outer batch is consuming line-by-line) and silently
|
||||
# exit. Surface it as an explicit error block so callers can audit the
|
||||
# mis-routed line.
|
||||
if args and args[0] == "batch":
|
||||
_batch_write_error(
|
||||
"RecursiveBatchError",
|
||||
"nested 'batch' invocation is not allowed inside batch mode",
|
||||
)
|
||||
_batch_flush_eor()
|
||||
continue
|
||||
|
||||
_dispatch_batch_line(args)
|
||||
|
||||
|
||||
def _dispatch_batch_line(args: list[str]) -> None:
|
||||
"""Run a single batch line through the Click parser directly (no CliRunner).
|
||||
|
||||
Captures the subcommand's stdout via :func:`contextlib.redirect_stdout` and
|
||||
synthesises the standard ``{"error": ..., "type": ...}`` shape on failure.
|
||||
Click exceptions (`ClickException`, `UsageError`) are caught and rendered;
|
||||
`SystemExit(0)` from a Click command is treated as a clean exit, while a
|
||||
non-zero `SystemExit` is rendered as a CLI error. All other exceptions are
|
||||
captured and rendered as `{"error": str(exc), "type": exc.__class__.__name__}`
|
||||
so the loop never dies.
|
||||
"""
|
||||
|
||||
buffer = io.StringIO()
|
||||
exit_code = 0
|
||||
exc: BaseException | None = None
|
||||
try:
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
try:
|
||||
# `standalone_mode=False` makes Click raise instead of calling
|
||||
# `sys.exit`; we still need to handle SystemExit because some
|
||||
# commands explicitly raise it (or `click.UsageError` converts
|
||||
# to a SystemExit under some entry-point paths).
|
||||
main.main(args=args, standalone_mode=False, prog_name="mxgw-py")
|
||||
except click.exceptions.Exit as click_exit:
|
||||
exit_code = click_exit.exit_code
|
||||
except click.ClickException as click_exc:
|
||||
exit_code = click_exc.exit_code
|
||||
exc = click_exc
|
||||
click.echo(f"Error: {click_exc.format_message()}", err=False)
|
||||
except SystemExit as sys_exit:
|
||||
code = sys_exit.code
|
||||
exit_code = int(code) if isinstance(code, int) else (0 if code is None else 1)
|
||||
except Exception as captured: # noqa: BLE001 — never let batch loop die
|
||||
exc = captured
|
||||
exit_code = 1
|
||||
|
||||
output = buffer.getvalue()
|
||||
if exit_code == 0 and exc is None:
|
||||
sys.stdout.write(output)
|
||||
else:
|
||||
if output.lstrip().startswith("{"):
|
||||
# Inner command already emitted JSON (e.g. a structured error) —
|
||||
# relay verbatim.
|
||||
sys.stdout.write(output)
|
||||
if output and not output.endswith("\n"):
|
||||
sys.stdout.write("\n")
|
||||
elif exc is not None:
|
||||
_batch_write_error(type(exc).__name__, str(exc))
|
||||
else:
|
||||
msg = output.strip()
|
||||
if msg.startswith("Error: "):
|
||||
msg = msg[len("Error: "):]
|
||||
_batch_write_error("CliError", msg)
|
||||
|
||||
_batch_flush_eor()
|
||||
|
||||
|
||||
def _batch_write_error(exc_type: str, message: str) -> None:
|
||||
"""Write a JSON error record to stdout in the standard batch error shape."""
|
||||
sys.stdout.write(json.dumps({"error": message, "type": exc_type}) + "\n")
|
||||
|
||||
|
||||
def _batch_flush_eor() -> None:
|
||||
"""Write the end-of-record sentinel and flush stdout."""
|
||||
sys.stdout.write(_BATCH_EOR + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def gateway_options(command: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Apply the shared gateway connection options to a Click command."""
|
||||
command = click.option("--endpoint", default="localhost:5000", show_default=True)(command)
|
||||
@@ -53,6 +170,13 @@ def gateway_options(command: Callable[..., Any]) -> Callable[..., Any]:
|
||||
command = click.option("--plaintext", is_flag=True, help="Use plaintext gRPC.")(command)
|
||||
command = click.option("--tls", "use_tls", is_flag=True, help="Use TLS gRPC.")(command)
|
||||
command = click.option("--ca-file", default=None, help="Custom root certificate file.")(command)
|
||||
command = click.option(
|
||||
"--require-certificate-validation",
|
||||
"require_certificate_validation",
|
||||
is_flag=True,
|
||||
help="Verify the TLS certificate against the system trust store "
|
||||
"instead of the lenient trust-on-first-use default.",
|
||||
)(command)
|
||||
command = click.option(
|
||||
"--server-name-override",
|
||||
default=None,
|
||||
@@ -185,6 +309,112 @@ def unsubscribe_bulk(**kwargs: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command("read-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--items", required=True, help="Comma-separated MXAccess tag addresses.")
|
||||
@click.option("--timeout-ms", default=0, type=int, show_default=True,
|
||||
help="Per-tag snapshot timeout in milliseconds. 0 = worker default.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def read_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess ReadBulk — cached value when advised, snapshot otherwise."""
|
||||
|
||||
_run(_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess WriteBulk — sequential Write per entry."""
|
||||
|
||||
_run(_write_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write2-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
|
||||
@click.option("--user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write2_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess Write2Bulk — timestamped sequential Write2 per entry."""
|
||||
|
||||
_run(_write2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write-secured-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--current-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write_secured_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess WriteSecuredBulk — credential-sensitive."""
|
||||
|
||||
_run(_write_secured_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("write-secured2-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
|
||||
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
|
||||
@click.option("--current-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write_secured2_bulk(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess WriteSecured2Bulk — timestamped + credential-sensitive."""
|
||||
|
||||
_run(_write_secured2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("bench-read-bulk")
|
||||
@gateway_options
|
||||
@click.option("--client-name", default="mxgw-python-bench", show_default=True)
|
||||
@click.option("--duration-seconds", default=30, type=int, show_default=True)
|
||||
@click.option("--warmup-seconds", default=3, type=int, show_default=True)
|
||||
@click.option("--bulk-size", default=6, type=int, show_default=True)
|
||||
@click.option("--tag-start", default=1, type=int, show_default=True)
|
||||
@click.option("--tag-prefix", default="TestMachine_", show_default=True)
|
||||
@click.option("--tag-attribute", default="TestChangingInt", show_default=True)
|
||||
@click.option("--timeout-ms", default=1500, type=int, show_default=True)
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def bench_read_bulk(**kwargs: Any) -> None:
|
||||
"""Cross-language ReadBulk stress benchmark.
|
||||
|
||||
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, and emits the shared JSON stats schema the
|
||||
scripts/bench-read-bulk.ps1 driver collates across all five clients.
|
||||
"""
|
||||
|
||||
_run(_bench_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))
|
||||
|
||||
|
||||
@main.command("stream-events")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@@ -202,6 +432,40 @@ def stream_events(**kwargs: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command("stream-alarms")
|
||||
@gateway_options
|
||||
@click.option("--filter-prefix", default="", help="Alarm-reference prefix filter.")
|
||||
@click.option("--max-messages", default=1, type=int, show_default=True)
|
||||
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def stream_alarms(**kwargs: Any) -> None:
|
||||
"""Stream a bounded number of messages from the gateway's central alarm feed."""
|
||||
|
||||
_run(
|
||||
_stream_alarms(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command("acknowledge-alarm")
|
||||
@gateway_options
|
||||
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
||||
@click.option("--comment", default="", help="Acknowledgement comment.")
|
||||
@click.option("--operator", default="", help="Operator user name.")
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def acknowledge_alarm(**kwargs: Any) -> None:
|
||||
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
||||
|
||||
_run(
|
||||
_acknowledge_alarm(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@@ -339,6 +603,232 @@ async def _unsubscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _read_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.read_bulk(
|
||||
kwargs["server_handle"],
|
||||
_parse_string_list(kwargs["items"]),
|
||||
timeout_ms=kwargs["timeout_ms"],
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
def _build_write_bulk_entries(kwargs: dict[str, Any]):
|
||||
"""Build (item_handle, MxValue) pairs for the bulk-write families.
|
||||
|
||||
The CLI accepts a single ``--type`` plus ``--values`` (comma-separated
|
||||
string-encoded values, one per ``--item-handles`` entry). Returns the
|
||||
parsed item-handle list and the per-entry MxValue protobuf instances —
|
||||
callers wrap these into the appropriate per-entry message type.
|
||||
"""
|
||||
|
||||
handles = _parse_int_list(kwargs["item_handles"])
|
||||
value_texts = _parse_string_list(kwargs["values"])
|
||||
if len(handles) != len(value_texts):
|
||||
raise click.UsageError(
|
||||
f"item-handles count ({len(handles)}) does not match values count ({len(value_texts)})",
|
||||
)
|
||||
parsed = [_parse_value(text, kwargs["value_type"]) for text in value_texts]
|
||||
values = [to_mx_value(v) for v in parsed]
|
||||
return handles, values
|
||||
|
||||
|
||||
async def _write_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
entries = [
|
||||
pb.WriteBulkEntry(item_handle=handle, user_id=kwargs["user_id"], value=value)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _write2_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
|
||||
entries = [
|
||||
pb.Write2BulkEntry(
|
||||
item_handle=handle,
|
||||
user_id=kwargs["user_id"],
|
||||
value=value,
|
||||
timestamp_value=timestamp_value,
|
||||
)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write2_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _write_secured_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
entries = [
|
||||
pb.WriteSecuredBulkEntry(
|
||||
item_handle=handle,
|
||||
current_user_id=kwargs["current_user_id"],
|
||||
verifier_user_id=kwargs["verifier_user_id"],
|
||||
value=value,
|
||||
)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write_secured_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _write_secured2_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
handles, values = _build_write_bulk_entries(kwargs)
|
||||
timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
|
||||
entries = [
|
||||
pb.WriteSecured2BulkEntry(
|
||||
item_handle=handle,
|
||||
current_user_id=kwargs["current_user_id"],
|
||||
verifier_user_id=kwargs["verifier_user_id"],
|
||||
value=value,
|
||||
timestamp_value=timestamp_value,
|
||||
)
|
||||
for handle, value in zip(handles, values)
|
||||
]
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
results = await session.write_secured2_bulk(
|
||||
kwargs["server_handle"],
|
||||
entries,
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"results": [_message_dict(result) for result in results]}
|
||||
|
||||
|
||||
async def _bench_read_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
"""ReadBulk stress benchmark — matches the .NET / Go / Rust / Java schema."""
|
||||
|
||||
bulk_size = int(kwargs["bulk_size"])
|
||||
if bulk_size < 1:
|
||||
raise click.UsageError("bulk-size must be positive")
|
||||
duration_seconds = int(kwargs["duration_seconds"])
|
||||
warmup_seconds = int(kwargs["warmup_seconds"])
|
||||
tag_start = int(kwargs["tag_start"])
|
||||
tag_prefix = kwargs["tag_prefix"]
|
||||
tag_attribute = kwargs["tag_attribute"]
|
||||
timeout_ms = int(kwargs["timeout_ms"])
|
||||
client_name = kwargs["client_name"]
|
||||
tags = [f"{tag_prefix}{i:03d}.{tag_attribute}" for i in range(tag_start, tag_start + bulk_size)]
|
||||
|
||||
async with await _connect(kwargs) as client:
|
||||
session = await client.open_session(client_session_name=client_name)
|
||||
server_handle = 0
|
||||
item_handles: list[int] = []
|
||||
try:
|
||||
server_handle = await session.register(client_name)
|
||||
subscribe_results = await session.subscribe_bulk(server_handle, tags)
|
||||
item_handles = [r.item_handle for r in subscribe_results if r.was_successful]
|
||||
|
||||
# Warm-up window so JIT / connection pool / first-call costs are
|
||||
# amortised before the measurement window opens.
|
||||
warmup_deadline = time.perf_counter() + warmup_seconds
|
||||
while time.perf_counter() < warmup_deadline:
|
||||
await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)
|
||||
|
||||
latencies_ms: list[float] = []
|
||||
total_results = 0
|
||||
cached_results = 0
|
||||
successful = 0
|
||||
failed = 0
|
||||
steady_start = time.perf_counter()
|
||||
steady_deadline = steady_start + duration_seconds
|
||||
while time.perf_counter() < steady_deadline:
|
||||
call_start = time.perf_counter()
|
||||
try:
|
||||
results = await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)
|
||||
except Exception:
|
||||
failed += 1
|
||||
latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
|
||||
continue
|
||||
latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
|
||||
successful += 1
|
||||
for r in results:
|
||||
total_results += 1
|
||||
if r.was_cached:
|
||||
cached_results += 1
|
||||
steady_elapsed = time.perf_counter() - steady_start
|
||||
total_calls = successful + failed
|
||||
calls_per_second = total_calls / steady_elapsed if steady_elapsed > 0 else 0.0
|
||||
finally:
|
||||
if item_handles:
|
||||
try:
|
||||
await session.unsubscribe_bulk(server_handle, item_handles)
|
||||
except Exception as exc: # noqa: BLE001 — bench is best-effort
|
||||
logger.warning("bench-read-bulk: unsubscribe_bulk cleanup failed: %s", exc)
|
||||
try:
|
||||
await session.close()
|
||||
except Exception as exc: # noqa: BLE001 — bench is best-effort
|
||||
logger.warning("bench-read-bulk: session.close cleanup failed: %s", exc)
|
||||
|
||||
return {
|
||||
"language": "python",
|
||||
"command": "bench-read-bulk",
|
||||
"endpoint": kwargs.get("endpoint"),
|
||||
"clientName": client_name,
|
||||
"bulkSize": bulk_size,
|
||||
"durationSeconds": duration_seconds,
|
||||
"warmupSeconds": warmup_seconds,
|
||||
"durationMs": int(steady_elapsed * 1000),
|
||||
"tags": tags,
|
||||
"totalCalls": total_calls,
|
||||
"successfulCalls": successful,
|
||||
"failedCalls": failed,
|
||||
"totalReadResults": total_results,
|
||||
"cachedReadResults": cached_results,
|
||||
"callsPerSecond": round(calls_per_second, 2),
|
||||
"latencyMs": _percentile_summary(latencies_ms),
|
||||
}
|
||||
|
||||
|
||||
def _percentile_summary(sample: list[float]) -> dict[str, float]:
|
||||
if not sample:
|
||||
return {"p50": 0.0, "p95": 0.0, "p99": 0.0, "max": 0.0, "mean": 0.0}
|
||||
sorted_sample = sorted(sample)
|
||||
return {
|
||||
"p50": round(_percentile(sorted_sample, 0.50), 3),
|
||||
"p95": round(_percentile(sorted_sample, 0.95), 3),
|
||||
"p99": round(_percentile(sorted_sample, 0.99), 3),
|
||||
"max": round(sorted_sample[-1], 3),
|
||||
"mean": round(sum(sample) / len(sample), 3),
|
||||
}
|
||||
|
||||
|
||||
def _percentile(sorted_sample: list[float], quantile: float) -> float:
|
||||
"""Nearest-rank with linear interpolation; matches every other client."""
|
||||
n = len(sorted_sample)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
if n == 1:
|
||||
return sorted_sample[0]
|
||||
rank = quantile * (n - 1)
|
||||
lower = int(rank)
|
||||
upper = min(lower + 1, n - 1)
|
||||
fraction = rank - lower
|
||||
return sorted_sample[lower] + (sorted_sample[upper] - sorted_sample[lower]) * fraction
|
||||
|
||||
|
||||
async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
@@ -350,6 +840,34 @@ async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"events": [_message_dict(event) for event in events]}
|
||||
|
||||
|
||||
async def _stream_alarms(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
messages = await _collect_alarm_messages(
|
||||
client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_filter_prefix=kwargs["filter_prefix"],
|
||||
),
|
||||
),
|
||||
max_messages=kwargs["max_messages"],
|
||||
timeout=kwargs["timeout"],
|
||||
)
|
||||
return {"messages": [_message_dict(message) for message in messages]}
|
||||
|
||||
|
||||
async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
reply = await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_full_reference=kwargs["reference"],
|
||||
comment=kwargs["comment"],
|
||||
operator_user=kwargs["operator"],
|
||||
),
|
||||
)
|
||||
return _message_dict(reply)
|
||||
|
||||
|
||||
async def _write(**kwargs: Any) -> dict[str, Any]:
|
||||
value = _parse_value(kwargs["value"], kwargs["value_type"])
|
||||
async with await _connect(kwargs) as client:
|
||||
@@ -412,6 +930,7 @@ async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
|
||||
api_key=api_key,
|
||||
plaintext=_use_plaintext(kwargs),
|
||||
ca_file=kwargs.get("ca_file"),
|
||||
require_certificate_validation=bool(kwargs.get("require_certificate_validation")),
|
||||
server_name_override=kwargs.get("server_name_override"),
|
||||
call_timeout=kwargs.get("call_timeout"),
|
||||
stream_timeout=kwargs.get("stream_timeout"),
|
||||
@@ -426,11 +945,21 @@ def _session(client: GatewayClient, session_id: str):
|
||||
|
||||
|
||||
def _use_plaintext(kwargs: dict[str, Any]) -> bool:
|
||||
if kwargs.get("use_tls"):
|
||||
return False
|
||||
if kwargs.get("plaintext"):
|
||||
return True
|
||||
return kwargs["endpoint"].startswith("localhost:") or kwargs["endpoint"].startswith("127.0.0.1:")
|
||||
"""Resolve the plaintext / TLS contract from the CLI flags.
|
||||
|
||||
TLS is the default. ``--plaintext`` is the only way to opt in to an
|
||||
unencrypted channel; ``--tls`` is accepted as a redundant explicit
|
||||
affirmation. Combining the two is a usage error (regression-guarded by
|
||||
Client.Python-023 — the previous silent ``localhost:`` /
|
||||
``127.0.0.1:`` auto-plaintext branch leaked the API-key bearer over a
|
||||
plaintext channel when a user ran the gateway behind TLS on loopback).
|
||||
"""
|
||||
|
||||
plaintext = bool(kwargs.get("plaintext"))
|
||||
use_tls = bool(kwargs.get("use_tls"))
|
||||
if plaintext and use_tls:
|
||||
raise click.UsageError("--plaintext and --tls are mutually exclusive")
|
||||
return plaintext
|
||||
|
||||
|
||||
def _api_key_from_env(name: str | None) -> str | None:
|
||||
@@ -501,6 +1030,34 @@ async def _collect_events(
|
||||
return collected
|
||||
|
||||
|
||||
async def _collect_alarm_messages(
|
||||
messages: Any,
|
||||
*,
|
||||
max_messages: int,
|
||||
timeout: float,
|
||||
) -> list[pb.AlarmFeedMessage]:
|
||||
if max_messages > MAX_AGGREGATE_EVENTS:
|
||||
raise click.BadParameter(
|
||||
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||
param_hint="--max-messages",
|
||||
)
|
||||
|
||||
collected: list[pb.AlarmFeedMessage] = []
|
||||
iterator = messages.__aiter__()
|
||||
try:
|
||||
while len(collected) < max_messages:
|
||||
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
return collected
|
||||
|
||||
|
||||
def _parse_value(raw_value: str, value_type: str) -> MxValueInput:
|
||||
normalized = value_type.lower()
|
||||
if normalized == "bool":
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Tests for auth metadata and connection options."""
|
||||
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from zb_mom_ww_mxgateway.auth import REDACTED, ApiKey, auth_metadata, redact_secret
|
||||
from zb_mom_ww_mxgateway import options as options_module
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
|
||||
from zb_mom_ww_mxgateway.options import ClientOptions, create_channel
|
||||
|
||||
|
||||
@@ -72,27 +75,85 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
|
||||
]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[str, object, object]] = []
|
||||
def test_create_channel_uses_tls_channel_tofu_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Default TLS (no ca_file, no require_certificate_validation) uses TOFU:
|
||||
fetches the server cert unverified, pins it as root_certificates, and adds
|
||||
grpc.ssl_target_name_override = "localhost" automatically.
|
||||
"""
|
||||
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
|
||||
get_cert_calls: list[tuple[str, int]] = []
|
||||
|
||||
def fake_credentials(*, root_certificates: object) -> str:
|
||||
assert root_certificates is None
|
||||
def fake_get_server_certificate(
|
||||
addr: tuple[str, int], *, timeout: float | None = None
|
||||
) -> str:
|
||||
get_cert_calls.append(addr)
|
||||
return _DUMMY_PEM
|
||||
|
||||
cred_calls: list[object] = []
|
||||
|
||||
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||
cred_calls.append(root_certificates)
|
||||
return "creds"
|
||||
|
||||
channel_calls: list[tuple[str, object, object]] = []
|
||||
|
||||
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||
calls.append((endpoint, credentials, options))
|
||||
channel_calls.append((endpoint, credentials, options))
|
||||
return "tls-channel"
|
||||
|
||||
monkeypatch.setattr(
|
||||
options_module.grpc,
|
||||
"ssl_channel_credentials",
|
||||
fake_credentials,
|
||||
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||
|
||||
channel = create_channel(
|
||||
ClientOptions(endpoint="gateway.example:5001"),
|
||||
)
|
||||
|
||||
assert channel == "tls-channel"
|
||||
# TOFU: should have fetched the cert from the server (host, port)
|
||||
assert get_cert_calls == [("gateway.example", 5001)]
|
||||
# Pinned the fetched PEM bytes as root_certificates
|
||||
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
|
||||
# Auto-injected localhost override (no server_name_override supplied)
|
||||
assert channel_calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
("grpc.ssl_target_name_override", "localhost"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel_tofu_respects_server_name_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When server_name_override is set, TOFU still runs but does NOT add the
|
||||
auto-localhost override (the explicit override is already in channel_options).
|
||||
"""
|
||||
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
|
||||
monkeypatch.setattr(
|
||||
options_module.grpc.aio,
|
||||
"secure_channel",
|
||||
fake_secure_channel,
|
||||
options_module.ssl,
|
||||
"get_server_certificate",
|
||||
lambda addr, *, timeout=None: _DUMMY_PEM,
|
||||
)
|
||||
cred_calls: list[object] = []
|
||||
|
||||
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||
cred_calls.append(root_certificates)
|
||||
return "creds"
|
||||
|
||||
channel_calls: list[tuple[str, object, object]] = []
|
||||
|
||||
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||
channel_calls.append((endpoint, credentials, options))
|
||||
return "tls-channel"
|
||||
|
||||
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||
|
||||
channel = create_channel(
|
||||
ClientOptions(
|
||||
@@ -102,14 +163,164 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
)
|
||||
|
||||
assert channel == "tls-channel"
|
||||
assert calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
("grpc.ssl_target_name_override", "gateway.test"),
|
||||
],
|
||||
),
|
||||
]
|
||||
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
|
||||
assert channel_calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
# Explicit override from ClientOptions — not the auto-localhost one
|
||||
("grpc.ssl_target_name_override", "gateway.test"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel_require_cert_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""require_certificate_validation=True uses system trust (no TOFU, no root_certificates)."""
|
||||
get_cert_called = False
|
||||
|
||||
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
|
||||
nonlocal get_cert_called
|
||||
get_cert_called = True
|
||||
return "SHOULD_NOT_BE_CALLED"
|
||||
|
||||
cred_calls: list[object] = []
|
||||
|
||||
def fake_credentials(**kwargs: object) -> str:
|
||||
cred_calls.append(kwargs)
|
||||
return "creds"
|
||||
|
||||
channel_calls: list[tuple[str, object, object]] = []
|
||||
|
||||
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||
channel_calls.append((endpoint, credentials, options))
|
||||
return "tls-channel"
|
||||
|
||||
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||
|
||||
channel = create_channel(
|
||||
ClientOptions(
|
||||
endpoint="gateway.example:5001",
|
||||
require_certificate_validation=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert channel == "tls-channel"
|
||||
# Must NOT call TOFU prefetch
|
||||
assert not get_cert_called
|
||||
# ssl_channel_credentials() called with NO keyword args (system trust)
|
||||
assert cred_calls == [{}]
|
||||
assert channel_calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel_ca_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: pytest.TempPathFactory,
|
||||
) -> None:
|
||||
"""ca_file path: reads the PEM file, passes bytes as root_certificates, skips TOFU."""
|
||||
ca_pem = b"-----BEGIN CERTIFICATE-----\nY2FkYXRh\n-----END CERTIFICATE-----\n"
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_bytes(ca_pem)
|
||||
|
||||
get_cert_called = False
|
||||
|
||||
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
|
||||
nonlocal get_cert_called
|
||||
get_cert_called = True
|
||||
return "SHOULD_NOT_BE_CALLED"
|
||||
|
||||
cred_calls: list[object] = []
|
||||
|
||||
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||
cred_calls.append(root_certificates)
|
||||
return "creds"
|
||||
|
||||
channel_calls: list[tuple[str, object, object]] = []
|
||||
|
||||
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||
channel_calls.append((endpoint, credentials, options))
|
||||
return "tls-channel"
|
||||
|
||||
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||
|
||||
channel = create_channel(
|
||||
ClientOptions(
|
||||
endpoint="gateway.example:5001",
|
||||
ca_file=str(ca_file),
|
||||
),
|
||||
)
|
||||
|
||||
assert channel == "tls-channel"
|
||||
assert not get_cert_called
|
||||
assert cred_calls == [ca_pem]
|
||||
assert channel_calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_tofu_probe_passes_a_bounded_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The TOFU cert pre-fetch must be bounded so a black-holed host fails fast."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_get_server_certificate(addr: object, *, timeout: float | None = None) -> str:
|
||||
captured["timeout"] = timeout
|
||||
return "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
|
||||
|
||||
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", lambda **_: "creds")
|
||||
monkeypatch.setattr(
|
||||
options_module.grpc.aio,
|
||||
"secure_channel",
|
||||
lambda endpoint, credentials, *, options: "tls-channel",
|
||||
)
|
||||
|
||||
create_channel(ClientOptions(endpoint="gateway.example:5001", call_timeout=7.5))
|
||||
|
||||
# A finite, positive timeout must be supplied (bounded by call_timeout here).
|
||||
assert isinstance(captured["timeout"], (int, float))
|
||||
assert 0 < captured["timeout"] <= 7.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raised",
|
||||
[socket.timeout("timed out"), TimeoutError("timed out"), OSError("connection refused")],
|
||||
)
|
||||
def test_tofu_probe_timeout_raises_transport_error(
|
||||
monkeypatch: pytest.MonkeyPatch, raised: Exception
|
||||
) -> None:
|
||||
"""A timed-out / failed probe surfaces as MxGatewayTransportError, not a raw error."""
|
||||
|
||||
def fake_get_server_certificate(addr: object, *, timeout: float | None = None) -> str:
|
||||
raise raised
|
||||
|
||||
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||
|
||||
options = ClientOptions(endpoint="gateway.example:5001")
|
||||
with pytest.raises(MxGatewayTransportError) as excinfo:
|
||||
create_channel(options)
|
||||
assert options.endpoint in str(excinfo.value)
|
||||
|
||||
@@ -2,11 +2,78 @@
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from zb_mom_ww_mxgateway import __version__
|
||||
from zb_mom_ww_mxgateway_cli import commands as commands_module
|
||||
from zb_mom_ww_mxgateway_cli.commands import main
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
|
||||
def test_require_certificate_validation_flag_flows_through_connect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The --require-certificate-validation CLI flag must reach ClientOptions (Client.Python-027)."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_connect(options, **_kwargs):
|
||||
captured["options"] = options
|
||||
# Return a minimal object that supports the async context-manager protocol
|
||||
# used by every CLI command body (async with await _connect(...) as client).
|
||||
return _FakeAsyncClient()
|
||||
|
||||
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"open-session",
|
||||
"--endpoint",
|
||||
"gateway.example:5001",
|
||||
"--require-certificate-validation",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["options"].require_certificate_validation is True
|
||||
|
||||
|
||||
def test_require_certificate_validation_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Without the flag the strict-validation posture stays off (TOFU default)."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_connect(options, **_kwargs):
|
||||
captured["options"] = options
|
||||
return _FakeAsyncClient()
|
||||
|
||||
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
["open-session", "--endpoint", "gateway.example:5001", "--plaintext", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["options"].require_certificate_validation is False
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""Minimal async-context-manager fake satisfying the open-session command body."""
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: object) -> None:
|
||||
return None
|
||||
|
||||
async def open_session_raw(self, *_args, **_kwargs):
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
return pb.OpenSessionReply(session_id="cli-test-session")
|
||||
|
||||
|
||||
def test_version_json_is_deterministic() -> None:
|
||||
runner = CliRunner()
|
||||
@@ -48,6 +115,28 @@ def test_write_parser_rejects_unknown_value_type() -> None:
|
||||
assert "unsupported value type" in result.output
|
||||
|
||||
|
||||
def test_stream_alarms_is_registered() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(main, ["stream-alarms", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "--filter-prefix" in result.output
|
||||
assert "--max-messages" in result.output
|
||||
|
||||
|
||||
def test_acknowledge_alarm_requires_reference() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["acknowledge-alarm", "--api-key", "mxgw_test_secret", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--reference" in result.output
|
||||
|
||||
|
||||
def test_cli_error_output_redacts_api_key() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -66,3 +155,59 @@ def test_cli_error_output_redacts_api_key() -> None:
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "mxgw_test_secret" not in result.output
|
||||
|
||||
|
||||
def test_batch_runs_version_command_and_writes_eor() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(main, ["batch"], input="version --json\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block]
|
||||
assert len(blocks) == 1
|
||||
payload = json.loads(blocks[0].strip())
|
||||
assert payload == {
|
||||
"client": "mxgw-py",
|
||||
"package": "mxaccess-gateway-client",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
def test_batch_terminates_on_empty_line() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch"],
|
||||
input="version --json\n\nversion --json\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Only the first command runs; the empty line breaks the loop before the second.
|
||||
assert result.output.count(_BATCH_EOR) == 1
|
||||
|
||||
|
||||
def test_batch_continues_after_error_line() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
# First line is invalid (unknown subcommand), second is a valid version call.
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch"],
|
||||
input="not-a-real-command\nversion --json\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output.count(_BATCH_EOR) == 2
|
||||
|
||||
blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block]
|
||||
assert len(blocks) == 2
|
||||
|
||||
# First block: error JSON ({"error": "...", "type": "..."}).
|
||||
error_payload = json.loads(blocks[0].strip().splitlines()[-1])
|
||||
assert "error" in error_payload
|
||||
assert "type" in error_payload
|
||||
|
||||
# Second block: successful version JSON.
|
||||
version_payload = json.loads(blocks[1].strip())
|
||||
assert version_payload["version"] == __version__
|
||||
|
||||
@@ -8,9 +8,107 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient, MxAccessError
|
||||
from zb_mom_ww_mxgateway import client as client_module
|
||||
from zb_mom_ww_mxgateway import galaxy as galaxy_module
|
||||
from zb_mom_ww_mxgateway.galaxy import GalaxyRepositoryClient
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_connect_forwards_require_certificate_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The connect convenience kwarg must reach ClientOptions (Client.Python-027)."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
captured["options"] = options
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
|
||||
|
||||
await GatewayClient.connect(
|
||||
endpoint="gateway.example:5001",
|
||||
require_certificate_validation=True,
|
||||
)
|
||||
|
||||
assert captured["options"].require_certificate_validation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_galaxy_connect_forwards_require_certificate_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""GalaxyRepositoryClient.connect must thread the flag too (Client.Python-027)."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
captured["options"] = options
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(
|
||||
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
|
||||
)
|
||||
|
||||
await GalaxyRepositoryClient.connect(
|
||||
endpoint="gateway.example:5001",
|
||||
require_certificate_validation=True,
|
||||
)
|
||||
|
||||
assert captured["options"].require_certificate_validation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_connect_runs_create_channel_off_the_event_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""connect must run the blocking channel factory off the loop (Client.Python-028)."""
|
||||
ran_in_thread: dict[str, bool] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
# If this runs on the event loop thread, get_running_loop() succeeds.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
ran_in_thread["off_loop"] = False
|
||||
except RuntimeError:
|
||||
ran_in_thread["off_loop"] = True
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
|
||||
|
||||
await GatewayClient.connect(endpoint="gateway.example:5001")
|
||||
|
||||
assert ran_in_thread["off_loop"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_galaxy_connect_runs_create_channel_off_the_event_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""GalaxyRepositoryClient.connect must also run the probe off the loop (Client.Python-028)."""
|
||||
ran_in_thread: dict[str, bool] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
ran_in_thread["off_loop"] = False
|
||||
except RuntimeError:
|
||||
ran_in_thread["off_loop"] = True
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(
|
||||
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
|
||||
)
|
||||
|
||||
await GalaxyRepositoryClient.connect(endpoint="gateway.example:5001")
|
||||
|
||||
assert ran_in_thread["off_loop"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_helpers_send_auth_metadata_and_preserve_raw_replies() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
|
||||
@@ -6,12 +6,16 @@ import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||
from zb_mom_ww_mxgateway.galaxy import LazyBrowseNode
|
||||
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions
|
||||
|
||||
|
||||
def test_galaxy_messages_import() -> None:
|
||||
@@ -268,15 +272,281 @@ async def test_close_marks_channel_closed_when_no_real_channel() -> None:
|
||||
await client.close()
|
||||
|
||||
|
||||
def _obj(gid: int, tag: str, is_area: bool = False) -> galaxy_pb.GalaxyObject:
|
||||
return galaxy_pb.GalaxyObject(
|
||||
gobject_id=gid, tag_name=tag, browse_name=tag, is_area=is_area,
|
||||
)
|
||||
|
||||
|
||||
def _build_browse_reply(
|
||||
children: list[galaxy_pb.GalaxyObject],
|
||||
child_has_children: list[bool],
|
||||
cache_sequence: int,
|
||||
next_page_token: str = "",
|
||||
) -> galaxy_pb.BrowseChildrenReply:
|
||||
reply = galaxy_pb.BrowseChildrenReply(
|
||||
total_child_count=len(children),
|
||||
cache_sequence=cache_sequence,
|
||||
next_page_token=next_page_token,
|
||||
)
|
||||
reply.children.extend(children)
|
||||
reply.child_has_children.extend(child_has_children)
|
||||
return reply
|
||||
|
||||
|
||||
def _fake_aio_rpc_error(code: grpc.StatusCode, details: str) -> grpc.aio.AioRpcError:
|
||||
return grpc.aio.AioRpcError(
|
||||
code=code,
|
||||
initial_metadata=grpc.aio.Metadata(),
|
||||
trailing_metadata=grpc.aio.Metadata(),
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_no_parent_returns_roots() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.browse_children.replies = [
|
||||
_build_browse_reply(
|
||||
children=[_obj(1, "Area_A", is_area=True), _obj(2, "Area_B", is_area=True)],
|
||||
child_has_children=[True, False],
|
||||
cache_sequence=7,
|
||||
),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
roots = await client.browse()
|
||||
|
||||
assert len(roots) == 2
|
||||
assert all(isinstance(node, LazyBrowseNode) for node in roots)
|
||||
assert roots[0].object.tag_name == "Area_A"
|
||||
assert roots[0].has_children_hint is True
|
||||
assert roots[1].has_children_hint is False
|
||||
assert roots[0].is_expanded is False
|
||||
request = stub.browse_children.requests[0]
|
||||
assert request.WhichOneof("parent") is None
|
||||
assert request.page_size == 500
|
||||
assert request.page_token == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_expand_populates_children_and_marks_expanded() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.browse_children.replies = [
|
||||
_build_browse_reply(
|
||||
children=[_obj(1, "Area_A", is_area=True)],
|
||||
child_has_children=[True],
|
||||
cache_sequence=1,
|
||||
),
|
||||
_build_browse_reply(
|
||||
children=[_obj(11, "Child_A"), _obj(12, "Child_B")],
|
||||
child_has_children=[False, False],
|
||||
cache_sequence=1,
|
||||
),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
roots = await client.browse()
|
||||
await roots[0].expand()
|
||||
|
||||
assert roots[0].is_expanded is True
|
||||
assert [n.object.tag_name for n in roots[0].children] == ["Child_A", "Child_B"]
|
||||
assert len(stub.browse_children.requests) == 2
|
||||
expand_request = stub.browse_children.requests[1]
|
||||
assert expand_request.WhichOneof("parent") == "parent_gobject_id"
|
||||
assert expand_request.parent_gobject_id == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_expand_idempotent_no_second_rpc() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.browse_children.replies = [
|
||||
_build_browse_reply(
|
||||
children=[_obj(1, "Area_A", is_area=True)],
|
||||
child_has_children=[True],
|
||||
cache_sequence=1,
|
||||
),
|
||||
_build_browse_reply(
|
||||
children=[_obj(11, "Child_A")],
|
||||
child_has_children=[False],
|
||||
cache_sequence=1,
|
||||
),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
roots = await client.browse()
|
||||
await roots[0].expand()
|
||||
await roots[0].expand()
|
||||
|
||||
assert len(stub.browse_children.requests) == 2
|
||||
assert len(roots[0].children) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_expand_concurrent_callers_only_fire_one_rpc() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.browse_children.replies = [
|
||||
_build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7),
|
||||
_build_browse_reply([_obj(2, "Mixer_001")], [False], 7),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
roots = await client.browse()
|
||||
# Ten concurrent expand calls on the same node should issue exactly one RPC.
|
||||
await asyncio.gather(*(roots[0].expand() for _ in range(10)))
|
||||
|
||||
assert roots[0].is_expanded
|
||||
assert len(roots[0].children) == 1
|
||||
# 1 roots fetch + exactly 1 expand fetch = 2 total
|
||||
assert len(stub.browse_children.requests) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.browse_children.replies = [
|
||||
_build_browse_reply(
|
||||
children=[_obj(99, "Stale_Parent", is_area=True)],
|
||||
child_has_children=[True],
|
||||
cache_sequence=1,
|
||||
),
|
||||
]
|
||||
stub.browse_children.exceptions = [
|
||||
None,
|
||||
_fake_aio_rpc_error(grpc.StatusCode.NOT_FOUND, "parent not found"),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
roots = await client.browse()
|
||||
with pytest.raises(MxGatewayError):
|
||||
await roots[0].expand()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_expand_multi_page_gathers_all_pages() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.browse_children.replies = [
|
||||
_build_browse_reply(
|
||||
children=[_obj(7, "Area_Big", is_area=True)],
|
||||
child_has_children=[True],
|
||||
cache_sequence=2,
|
||||
),
|
||||
_build_browse_reply(
|
||||
children=[_obj(71, "Child_1"), _obj(72, "Child_2")],
|
||||
child_has_children=[False, False],
|
||||
cache_sequence=2,
|
||||
next_page_token="7:abc:2",
|
||||
),
|
||||
_build_browse_reply(
|
||||
children=[_obj(73, "Child_3")],
|
||||
child_has_children=[False],
|
||||
cache_sequence=2,
|
||||
),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
roots = await client.browse()
|
||||
await roots[0].expand()
|
||||
|
||||
assert [n.object.tag_name for n in roots[0].children] == ["Child_1", "Child_2", "Child_3"]
|
||||
assert len(stub.browse_children.requests) == 3
|
||||
assert stub.browse_children.requests[2].page_token == "7:abc:2"
|
||||
assert stub.browse_children.requests[2].parent_gobject_id == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_with_filter_forwards_to_request() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.browse_children.replies = [
|
||||
_build_browse_reply(
|
||||
children=[_obj(1, "Area_A", is_area=True)],
|
||||
child_has_children=[False],
|
||||
cache_sequence=3,
|
||||
),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
options = BrowseChildrenOptions(
|
||||
category_ids=(4, 5),
|
||||
template_chain_contains=("$DelmiaReceiver",),
|
||||
tag_name_glob="Area_*",
|
||||
include_attributes=True,
|
||||
alarm_bearing_only=True,
|
||||
historized_only=True,
|
||||
)
|
||||
|
||||
await client.browse(options)
|
||||
|
||||
request = stub.browse_children.requests[0]
|
||||
assert list(request.category_ids) == [4, 5]
|
||||
assert list(request.template_chain_contains) == ["$DelmiaReceiver"]
|
||||
assert request.tag_name_glob == "Area_*"
|
||||
assert request.HasField("include_attributes")
|
||||
assert request.include_attributes is True
|
||||
assert request.alarm_bearing_only is True
|
||||
assert request.historized_only is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_children_raw_returns_reply_unwrapped() -> None:
|
||||
"""browse_children_raw forwards the request to the stub and returns the raw reply."""
|
||||
stub = FakeGalaxyStub()
|
||||
expected = _build_browse_reply(
|
||||
children=[_obj(1, "Plant", is_area=True)],
|
||||
child_has_children=[True],
|
||||
cache_sequence=42,
|
||||
)
|
||||
stub.browse_children.replies = [expected]
|
||||
|
||||
async with await GalaxyRepositoryClient.connect(
|
||||
endpoint="fake",
|
||||
plaintext=True,
|
||||
stub=stub,
|
||||
) as client:
|
||||
request = galaxy_pb.BrowseChildrenRequest(
|
||||
page_size=10,
|
||||
tag_name_glob="Plant*",
|
||||
)
|
||||
reply = await client.browse_children_raw(request)
|
||||
|
||||
assert reply.cache_sequence == 42
|
||||
assert len(reply.children) == 1
|
||||
assert reply.children[0].tag_name == "Plant"
|
||||
assert len(stub.browse_children.requests) == 1
|
||||
assert stub.browse_children.requests[0].tag_name_glob == "Plant*"
|
||||
|
||||
|
||||
class FakeGalaxyStub:
|
||||
def __init__(self) -> None:
|
||||
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
||||
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
|
||||
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
|
||||
self.browse_children = FakeUnary([galaxy_pb.BrowseChildrenReply()])
|
||||
self.watch_deploy_events = FakeStream([])
|
||||
self.TestConnection = self.test_connection
|
||||
self.GetLastDeployTime = self.get_last_deploy_time
|
||||
self.DiscoverHierarchy = self.discover_hierarchy
|
||||
self.BrowseChildren = self.browse_children
|
||||
|
||||
@property
|
||||
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
|
||||
@@ -287,6 +557,8 @@ class FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
self.requests: list[Any] = []
|
||||
# None entries mean "no exception on this call"; aligns with the replies queue index-by-index.
|
||||
self.exceptions: list[BaseException | None] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(
|
||||
@@ -298,6 +570,10 @@ class FakeUnary:
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
if self.exceptions:
|
||||
exc = self.exceptions.pop(0)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
"""Regression tests for Client.Python-022..026.
|
||||
|
||||
Each test corresponds to a finding from the latest re-review. Tests are
|
||||
TDD-first — they failed against the pre-fix source and pass against the
|
||||
fixed source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time as _time_module_ref
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway_cli import commands as cli_commands
|
||||
from zb_mom_ww_mxgateway_cli.commands import _use_plaintext, main
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-022 — README CLI examples must parse against the implementation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _readme_path() -> Path:
|
||||
return Path(__file__).resolve().parent.parent / "README.md"
|
||||
|
||||
|
||||
def _extract_mxgw_py_examples() -> list[list[str]]:
|
||||
"""Return the README's ``mxgw-py ...`` example lines as click arg lists.
|
||||
|
||||
Replaces angle-bracket placeholders (``<id>``) with safe stub values and
|
||||
leaves real flag names untouched. The returned arg lists drop the
|
||||
``mxgw-py`` prefix.
|
||||
"""
|
||||
|
||||
text = _readme_path().read_text(encoding="utf-8")
|
||||
args: list[list[str]] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line.startswith("mxgw-py "):
|
||||
continue
|
||||
# Strip the leading "mxgw-py " token.
|
||||
body = line[len("mxgw-py ") :]
|
||||
# Replace common placeholders so click does not error on the placeholder.
|
||||
body = body.replace("<id>", "session-1")
|
||||
# Backtick-quoted hostnames in the TLS example are not represented
|
||||
# in CLI; safe to leave as-is.
|
||||
tokens = _split_cli_tokens(body)
|
||||
# Keep only examples that exercise a real subcommand. Skip TLS
|
||||
# multi-flag example (we only need the README CLI examples added in
|
||||
# commits 8738735 — stream-alarms / acknowledge-alarm).
|
||||
args.append(tokens)
|
||||
return args
|
||||
|
||||
|
||||
def _split_cli_tokens(body: str) -> list[str]:
|
||||
"""Split a CLI body into argv tokens, honouring double-quoted strings."""
|
||||
|
||||
tokens: list[str] = []
|
||||
pattern = re.compile(r'"([^"]*)"|(\S+)')
|
||||
for match in pattern.finditer(body):
|
||||
quoted, plain = match.group(1), match.group(2)
|
||||
tokens.append(quoted if quoted is not None else plain)
|
||||
return tokens
|
||||
|
||||
|
||||
def test_readme_alarm_examples_parse_against_cli() -> None:
|
||||
"""README `stream-alarms` / `acknowledge-alarm` examples must parse without
|
||||
triggering Click's ``no such option`` error.
|
||||
|
||||
Drives every README ``mxgw-py`` example through Click's ``--help`` style
|
||||
parser by re-invoking the documented argv with a trailing ``--help`` flag so
|
||||
only the parser runs (no RPC is attempted). If a documented flag does not
|
||||
exist on the subcommand, Click prints ``no such option: --<flag>`` and
|
||||
exits 2 — that is the regression we want to catch.
|
||||
"""
|
||||
|
||||
runner = CliRunner()
|
||||
examples = _extract_mxgw_py_examples()
|
||||
assert any(
|
||||
"stream-alarms" in args for args in examples
|
||||
), "README must include a stream-alarms example."
|
||||
assert any(
|
||||
"acknowledge-alarm" in args for args in examples
|
||||
), "README must include an acknowledge-alarm example."
|
||||
|
||||
for argv in examples:
|
||||
# Strip "--json" (already a real flag) and any value-bearing flag that
|
||||
# requires a host/file/value, then append --help so we exercise the
|
||||
# parser only.
|
||||
# We just append --help — Click parses all options up to --help and
|
||||
# then prints help; an unknown option still errors out first.
|
||||
result = runner.invoke(main, [*argv, "--help"])
|
||||
# Either help text printed (exit 0) or some other parser issue (exit 2);
|
||||
# we only want to assert NO "no such option" error.
|
||||
assert "no such option" not in result.output.lower(), (
|
||||
f"README example failed Click parsing: argv={argv!r}\n"
|
||||
f"output={result.output!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-023 — REGRESSION of Client.Python-013. _use_plaintext must
|
||||
# not silently auto-downgrade on localhost / 127.0.0.1.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_use_plaintext_does_not_auto_downgrade_for_localhost_endpoint() -> None:
|
||||
"""A bare ``localhost:...`` endpoint with no flags must default to TLS."""
|
||||
|
||||
assert _use_plaintext({
|
||||
"endpoint": "localhost:5001",
|
||||
"plaintext": False,
|
||||
"use_tls": False,
|
||||
}) is False
|
||||
|
||||
|
||||
def test_use_plaintext_does_not_auto_downgrade_for_loopback_ipv4_endpoint() -> None:
|
||||
"""A bare ``127.0.0.1:...`` endpoint with no flags must default to TLS."""
|
||||
|
||||
assert _use_plaintext({
|
||||
"endpoint": "127.0.0.1:5001",
|
||||
"plaintext": False,
|
||||
"use_tls": False,
|
||||
}) is False
|
||||
|
||||
|
||||
def test_use_plaintext_requires_explicit_plaintext_flag() -> None:
|
||||
"""``--plaintext`` is the only way to opt in."""
|
||||
|
||||
assert _use_plaintext({
|
||||
"endpoint": "localhost:5001",
|
||||
"plaintext": True,
|
||||
"use_tls": False,
|
||||
}) is True
|
||||
|
||||
|
||||
def test_use_plaintext_tls_flag_explicitly_disables_plaintext() -> None:
|
||||
"""``--tls`` is accepted as an explicit affirmation of the default."""
|
||||
|
||||
assert _use_plaintext({
|
||||
"endpoint": "localhost:5001",
|
||||
"plaintext": False,
|
||||
"use_tls": True,
|
||||
}) is False
|
||||
|
||||
|
||||
def test_use_plaintext_rejects_plaintext_and_tls_combined() -> None:
|
||||
"""``--plaintext`` and ``--tls`` together must be rejected as ambiguous."""
|
||||
|
||||
import click as _click
|
||||
|
||||
with pytest.raises(_click.UsageError):
|
||||
_use_plaintext({
|
||||
"endpoint": "localhost:5001",
|
||||
"plaintext": True,
|
||||
"use_tls": True,
|
||||
})
|
||||
|
||||
|
||||
def test_cli_localhost_endpoint_with_no_flags_uses_tls_channel(monkeypatch) -> None:
|
||||
"""End-to-end CLI: against ``localhost:...`` with no flags, the resolved
|
||||
``ClientOptions.plaintext`` flowing into ``GatewayClient.connect`` must be
|
||||
``False`` (TLS), so the API key bearer cannot leak over plaintext.
|
||||
"""
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _FakeStub:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def OpenSession(self, request: Any, *, metadata: tuple[Any, ...]) -> Any:
|
||||
captured["metadata"] = metadata
|
||||
return pb.OpenSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
)
|
||||
|
||||
real_connect = GatewayClient.connect
|
||||
|
||||
@classmethod
|
||||
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
|
||||
captured["options"] = options
|
||||
return await real_connect(options, stub=_FakeStub())
|
||||
|
||||
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"open-session",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--api-key",
|
||||
"mxgw_test_secret",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "options" in captured
|
||||
assert captured["options"].plaintext is False, (
|
||||
"localhost endpoint without --plaintext must NOT auto-downgrade to plaintext"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-024 — `batch` must not use CliRunner from production code,
|
||||
# and a recursive `batch` line must not silently re-enter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batch_command_does_not_use_clirunner_in_production() -> None:
|
||||
"""`commands.py` must not import or instantiate the test-only CliRunner helper.
|
||||
|
||||
Docstring references explaining what the module deliberately avoids are
|
||||
permitted; what is forbidden is an actual ``import`` of ``click.testing``
|
||||
or an actual ``CliRunner()`` instantiation in executable code.
|
||||
"""
|
||||
|
||||
source = Path(cli_commands.__file__).read_text(encoding="utf-8")
|
||||
assert "from click.testing" not in source, (
|
||||
"click.testing is a test-only helper and must not be used by production code"
|
||||
)
|
||||
assert "import click.testing" not in source, (
|
||||
"click.testing is a test-only helper and must not be used by production code"
|
||||
)
|
||||
# `CliRunner()` (instantiation) must not appear in production code.
|
||||
assert "CliRunner(" not in source, (
|
||||
"CliRunner() must not be instantiated in production code"
|
||||
)
|
||||
|
||||
|
||||
def test_batch_recursive_batch_line_is_bounded() -> None:
|
||||
"""A `batch` line nested inside `batch` stdin must not be silently spawned.
|
||||
|
||||
The pre-fix implementation re-invoked the test runner with empty stdin,
|
||||
so `batch` inside `batch` exited cleanly with no error. The fix either
|
||||
rejects the nested invocation or surfaces it as an error block so the
|
||||
behaviour is auditable.
|
||||
"""
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch"],
|
||||
input="batch\nversion --json\n",
|
||||
)
|
||||
|
||||
# Outer batch must still exit 0 and process both lines.
|
||||
assert result.exit_code == 0
|
||||
assert result.output.count(_BATCH_EOR) == 2
|
||||
|
||||
blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block]
|
||||
# The first block — the recursive `batch` line — must surface an error
|
||||
# JSON. (Either an explicit rejection, or some non-empty error block —
|
||||
# NOT a silently empty block.)
|
||||
first_block = blocks[0].strip()
|
||||
assert first_block, "recursive batch line must not be silently swallowed"
|
||||
payload = json.loads(first_block.splitlines()[-1])
|
||||
assert "error" in payload, (
|
||||
f"recursive batch line should surface an error: got {payload!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-025 — Behavioural tests for new bulk SDK methods,
|
||||
# stream_alarms, and the new CLI subcommands.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _AlarmFakeStream:
|
||||
def __init__(self, messages: list[pb.AlarmFeedMessage]) -> None:
|
||||
self._messages = list(messages)
|
||||
self.cancelled = False
|
||||
|
||||
def __aiter__(self) -> "_AlarmFakeStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> pb.AlarmFeedMessage:
|
||||
if not self._messages:
|
||||
raise StopAsyncIteration
|
||||
return self._messages.pop(0)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class _BulkFakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class _BulkFakeStub:
|
||||
def __init__(self) -> None:
|
||||
self.open_session = _BulkFakeUnary(
|
||||
[
|
||||
pb.OpenSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.invoke = _BulkFakeUnary([])
|
||||
self.OpenSession = self.open_session
|
||||
self.Invoke = self.invoke
|
||||
self.stream_alarms_metadata: tuple[tuple[str, str], ...] | None = None
|
||||
self._alarm_stream = _AlarmFakeStream([])
|
||||
|
||||
def set_invoke_replies(self, replies: list[Any]) -> None:
|
||||
self.invoke.replies = replies
|
||||
|
||||
def set_alarm_stream(self, stream: _AlarmFakeStream) -> None:
|
||||
self._alarm_stream = stream
|
||||
|
||||
def StreamAlarms(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
|
||||
self.stream_alarms_request = request
|
||||
self.stream_alarms_metadata = metadata
|
||||
return self._alarm_stream
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_read_bulk_sends_expected_request_shape() -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
read_bulk=pb.BulkReadReply(
|
||||
results=[
|
||||
pb.BulkReadResult(
|
||||
tag_address="Tank01.Level",
|
||||
was_successful=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
results = await session.read_bulk(12, ["Tank01.Level"], timeout_ms=1500)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].tag_address == "Tank01.Level"
|
||||
request = stub.invoke.requests[0]
|
||||
assert request.command.kind == pb.MX_COMMAND_KIND_READ_BULK
|
||||
assert request.command.read_bulk.server_handle == 12
|
||||
assert list(request.command.read_bulk.tag_addresses) == ["Tank01.Level"]
|
||||
assert request.command.read_bulk.timeout_ms == 1500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_write_bulk_sends_expected_request_shape() -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
write_bulk=pb.BulkWriteReply(
|
||||
results=[pb.BulkWriteResult(was_successful=True)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
from zb_mom_ww_mxgateway.values import to_mx_value
|
||||
|
||||
entries = [
|
||||
pb.WriteBulkEntry(item_handle=34, user_id=99, value=to_mx_value(123)),
|
||||
]
|
||||
results = await session.write_bulk(12, entries)
|
||||
|
||||
assert results[0].was_successful is True
|
||||
cmd = stub.invoke.requests[0].command
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
||||
assert cmd.write_bulk.server_handle == 12
|
||||
assert cmd.write_bulk.entries[0].item_handle == 34
|
||||
assert cmd.write_bulk.entries[0].user_id == 99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_write2_bulk_sends_expected_request_shape() -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
write2_bulk=pb.BulkWriteReply(
|
||||
results=[pb.BulkWriteResult(was_successful=True)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
from zb_mom_ww_mxgateway.values import to_mx_value
|
||||
|
||||
entries = [
|
||||
pb.Write2BulkEntry(
|
||||
item_handle=34,
|
||||
user_id=99,
|
||||
value=to_mx_value(123),
|
||||
timestamp_value=to_mx_value(1.5),
|
||||
),
|
||||
]
|
||||
results = await session.write2_bulk(12, entries)
|
||||
|
||||
assert results[0].was_successful is True
|
||||
cmd = stub.invoke.requests[0].command
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE2_BULK
|
||||
assert cmd.write2_bulk.server_handle == 12
|
||||
assert cmd.write2_bulk.entries[0].item_handle == 34
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_write_secured_bulk_sends_expected_request_shape() -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
write_secured_bulk=pb.BulkWriteReply(
|
||||
results=[pb.BulkWriteResult(was_successful=True)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
from zb_mom_ww_mxgateway.values import to_mx_value
|
||||
|
||||
entries = [
|
||||
pb.WriteSecuredBulkEntry(
|
||||
item_handle=34,
|
||||
current_user_id=42,
|
||||
verifier_user_id=43,
|
||||
value=to_mx_value("secret"),
|
||||
),
|
||||
]
|
||||
results = await session.write_secured_bulk(12, entries)
|
||||
|
||||
assert results[0].was_successful is True
|
||||
cmd = stub.invoke.requests[0].command
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED_BULK
|
||||
assert cmd.write_secured_bulk.server_handle == 12
|
||||
assert cmd.write_secured_bulk.entries[0].current_user_id == 42
|
||||
assert cmd.write_secured_bulk.entries[0].verifier_user_id == 43
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_write_secured2_bulk_sends_expected_request_shape() -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
write_secured2_bulk=pb.BulkWriteReply(
|
||||
results=[pb.BulkWriteResult(was_successful=True)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
from zb_mom_ww_mxgateway.values import to_mx_value
|
||||
|
||||
entries = [
|
||||
pb.WriteSecured2BulkEntry(
|
||||
item_handle=34,
|
||||
current_user_id=42,
|
||||
verifier_user_id=43,
|
||||
value=to_mx_value("secret"),
|
||||
timestamp_value=to_mx_value(1.5),
|
||||
),
|
||||
]
|
||||
results = await session.write_secured2_bulk(12, entries)
|
||||
|
||||
assert results[0].was_successful is True
|
||||
cmd = stub.invoke.requests[0].command
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK
|
||||
assert cmd.write_secured2_bulk.entries[0].current_user_id == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_alarms_yields_feed_messages_and_cancels_on_close() -> None:
|
||||
transitions = [
|
||||
pb.AlarmFeedMessage(
|
||||
transition=pb.OnAlarmTransitionEvent(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
|
||||
),
|
||||
),
|
||||
]
|
||||
stream = _AlarmFakeStream(transitions)
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_alarm_stream(stream)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
iterator = client.stream_alarms(pb.StreamAlarmsRequest(alarm_filter_prefix="Tank01."))
|
||||
first = await anext(iterator)
|
||||
await iterator.aclose()
|
||||
|
||||
assert first.transition.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert stream.cancelled
|
||||
assert stub.stream_alarms_metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
assert stub.stream_alarms_request.alarm_filter_prefix == "Tank01."
|
||||
|
||||
|
||||
# ---- CLI happy-path coverage for the new subcommands ----
|
||||
|
||||
|
||||
def _install_fake_connect(monkeypatch, stub: Any) -> dict[str, Any]:
|
||||
"""Patch `GatewayClient.connect` so the CLI uses the supplied fake stub."""
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
real_connect = GatewayClient.connect
|
||||
|
||||
@classmethod
|
||||
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
|
||||
captured["options"] = options
|
||||
return await real_connect(options, stub=stub)
|
||||
|
||||
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
|
||||
return captured
|
||||
|
||||
|
||||
def test_cli_read_bulk_happy_path(monkeypatch) -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
read_bulk=pb.BulkReadReply(
|
||||
results=[
|
||||
pb.BulkReadResult(
|
||||
tag_address="Tank01.Level",
|
||||
was_successful=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"read-bulk",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"12",
|
||||
"--items",
|
||||
"Tank01.Level",
|
||||
"--timeout-ms",
|
||||
"1500",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["results"][0]["tagAddress"] == "Tank01.Level"
|
||||
|
||||
|
||||
def test_cli_write_bulk_happy_path(monkeypatch) -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_invoke_replies(
|
||||
[
|
||||
pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
write_bulk=pb.BulkWriteReply(
|
||||
results=[pb.BulkWriteResult(was_successful=True)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"write-bulk",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"12",
|
||||
"--item-handles",
|
||||
"34",
|
||||
"--values",
|
||||
"123",
|
||||
"--type",
|
||||
"int32",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["results"][0]["wasSuccessful"] is True
|
||||
cmd = stub.invoke.requests[0].command
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
||||
|
||||
|
||||
def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
transitions = [
|
||||
pb.AlarmFeedMessage(
|
||||
transition=pb.OnAlarmTransitionEvent(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
|
||||
),
|
||||
),
|
||||
]
|
||||
stream = _AlarmFakeStream(transitions)
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_alarm_stream(stream)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"stream-alarms",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--max-messages",
|
||||
"1",
|
||||
"--timeout",
|
||||
"5.0",
|
||||
"--filter-prefix",
|
||||
"Tank01.",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["messages"][0]["transition"]["alarmFullReference"] == "Tank01.Level.HiHi"
|
||||
|
||||
|
||||
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
stub = _BulkFakeStub()
|
||||
stub.acknowledge_alarm = _BulkFakeUnary(
|
||||
[
|
||||
pb.AcknowledgeAlarmReply(
|
||||
correlation_id="corr-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
status=pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
stub.AcknowledgeAlarm = stub.acknowledge_alarm
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"acknowledge-alarm",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
"--comment",
|
||||
"investigating",
|
||||
"--operator",
|
||||
"alice",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
captured_request = stub.acknowledge_alarm.requests[0]
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-026 — `import time` at module scope; tighter cleanup excepts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_commands_module_imports_time_at_module_scope() -> None:
|
||||
"""`time` must be imported at module scope, not inside `_bench_read_bulk`.
|
||||
|
||||
`inspect.getsource(_bench_read_bulk)` must not contain a function-local
|
||||
``import time`` statement.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(cli_commands._bench_read_bulk)
|
||||
# The function body must NOT contain a function-local `import time` line.
|
||||
for line in source.splitlines():
|
||||
stripped = line.strip()
|
||||
assert stripped != "import time", (
|
||||
f"_bench_read_bulk must not have function-local `import time`: {line!r}"
|
||||
)
|
||||
|
||||
# And the module-level `time` attribute must be present.
|
||||
assert hasattr(cli_commands, "time"), (
|
||||
"`time` must be imported at module scope on commands.py"
|
||||
)
|
||||
assert cli_commands.time is _time_module_ref
|
||||
|
||||
|
||||
def test_commands_module_bench_read_bulk_does_not_use_bare_except_pass() -> None:
|
||||
"""The two `except Exception: pass` cleanup blocks in `_bench_read_bulk`
|
||||
must be removed in favour of either logging or a narrower exception class.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(cli_commands._bench_read_bulk)
|
||||
# Reject the bare `except Exception:` followed by `pass` pattern in
|
||||
# `_bench_read_bulk`. We tolerate `except Exception as <name>:` because the
|
||||
# fix logs the exception.
|
||||
pattern = re.compile(r"except\s+Exception\s*:\s*\n\s*pass\b")
|
||||
assert not pattern.search(source), (
|
||||
"_bench_read_bulk cleanup blocks must log or narrow the except clause"
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""TLS behaviour tests for ``create_channel``.
|
||||
|
||||
These spin up a real loopback ``grpc.aio`` server with a freshly generated
|
||||
self-signed certificate (carrying a ``localhost`` SAN, mirroring the gateway's
|
||||
auto-generated cert) and assert the lenient TOFU default lets a client connect
|
||||
without any CA configured.
|
||||
|
||||
Marked ``tls`` and skipped unless ``MXGATEWAY_RUN_TLS_TESTS=1`` because loopback
|
||||
TLS handshakes can be timing-flaky on shared CI runners. This mirrors how the
|
||||
suite gates anything that depends on real sockets rather than fakes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||
from zb_mom_ww_mxgateway.options import create_channel
|
||||
|
||||
pytestmark = pytest.mark.tls
|
||||
|
||||
_RUN_TLS_TESTS = os.environ.get("MXGATEWAY_RUN_TLS_TESTS") == "1"
|
||||
_OPENSSL = shutil.which("openssl")
|
||||
|
||||
requires_tls = pytest.mark.skipif(
|
||||
not _RUN_TLS_TESTS,
|
||||
reason="set MXGATEWAY_RUN_TLS_TESTS=1 to run loopback TLS tests",
|
||||
)
|
||||
requires_openssl = pytest.mark.skipif(
|
||||
_OPENSSL is None,
|
||||
reason="openssl CLI is required to generate a self-signed test certificate",
|
||||
)
|
||||
|
||||
|
||||
def _generate_self_signed_cert(directory: Path) -> tuple[Path, Path]:
|
||||
"""Generate a self-signed cert/key pair with a ``localhost`` SAN."""
|
||||
key_path = directory / "server.key"
|
||||
cert_path = directory / "server.crt"
|
||||
subprocess.run(
|
||||
[
|
||||
str(_OPENSSL),
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
str(key_path),
|
||||
"-out",
|
||||
str(cert_path),
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=mxgateway-test",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost,IP:127.0.0.1",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
class _StaticGatewayServicer(pb_grpc.MxAccessGatewayServicer):
|
||||
"""Minimal servicer answering ``OpenSession`` with a fixed session id."""
|
||||
|
||||
async def OpenSession( # noqa: N802 - generated gRPC method name
|
||||
self, request: pb.OpenSessionRequest, context: object
|
||||
) -> pb.OpenSessionReply:
|
||||
return pb.OpenSessionReply(session_id="tls-session-1")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def tls_server() -> AsyncIterator[int]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cert_path, key_path = _generate_self_signed_cert(Path(tmp))
|
||||
credentials = grpc.ssl_server_credentials(
|
||||
[(key_path.read_bytes(), cert_path.read_bytes())]
|
||||
)
|
||||
server = grpc.aio.server()
|
||||
pb_grpc.add_MxAccessGatewayServicer_to_server(_StaticGatewayServicer(), server)
|
||||
port = _free_port()
|
||||
server.add_secure_port(f"127.0.0.1:{port}", credentials)
|
||||
await server.start()
|
||||
try:
|
||||
yield port
|
||||
finally:
|
||||
await server.stop(grace=None)
|
||||
|
||||
|
||||
@requires_tls
|
||||
@requires_openssl
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_tls_connects_via_tofu(tls_server: int) -> None:
|
||||
"""Default TLS options (no CA) connect by pinning the presented cert."""
|
||||
options = ClientOptions(
|
||||
endpoint=f"127.0.0.1:{tls_server}",
|
||||
api_key="mxgw_test_secret",
|
||||
)
|
||||
channel = create_channel(options)
|
||||
try:
|
||||
stub = pb_grpc.MxAccessGatewayStub(channel)
|
||||
reply = await stub.OpenSession(pb.OpenSessionRequest(), timeout=10)
|
||||
assert reply.session_id == "tls-session-1"
|
||||
finally:
|
||||
await channel.close()
|
||||
|
||||
|
||||
def test_split_authority_parses_host_and_port() -> None:
|
||||
from zb_mom_ww_mxgateway.options import _split_authority
|
||||
|
||||
assert _split_authority("https://10.0.0.5:5120") == ("10.0.0.5", 5120)
|
||||
assert _split_authority("localhost:5120") == ("localhost", 5120)
|
||||
assert _split_authority(":5120") == ("localhost", 5120)
|
||||
|
||||
|
||||
def test_split_authority_defaults_port_for_portless_endpoint() -> None:
|
||||
from zb_mom_ww_mxgateway.options import _split_authority
|
||||
|
||||
# A bare hostname (no ":port") must default to 443, not crash on int("mygateway").
|
||||
assert _split_authority("mygateway") == ("mygateway", 443)
|
||||
# Scheme-prefixed bare hostname behaves the same.
|
||||
assert _split_authority("https://mygateway") == ("mygateway", 443)
|
||||
# A non-numeric tail after a colon is treated as no explicit port.
|
||||
assert _split_authority("mygateway:") == ("mygateway", 443)
|
||||
|
||||
|
||||
def test_split_authority_strips_ipv6_brackets() -> None:
|
||||
from zb_mom_ww_mxgateway.options import _split_authority
|
||||
|
||||
# Bracketed IPv6 with port — brackets must be removed for ssl.get_server_certificate
|
||||
assert _split_authority("[::1]:5120") == ("::1", 5120)
|
||||
# Bare bracketed IPv6 (no port) — default port 443
|
||||
assert _split_authority("[::1]") == ("::1", 443)
|
||||
# Scheme-prefixed bracketed IPv6
|
||||
assert _split_authority("grpc://[::1]:5120") == ("::1", 5120)
|
||||
|
||||
|
||||
def test_tofu_connect_failure_raises_transport_error() -> None:
|
||||
"""A failed cert pre-fetch surfaces the client's transport error type."""
|
||||
options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}")
|
||||
with pytest.raises(MxGatewayTransportError) as excinfo:
|
||||
create_channel(options)
|
||||
assert options.endpoint in str(excinfo.value)
|
||||
|
||||
|
||||
def test_require_certificate_validation_uses_system_trust() -> None:
|
||||
"""``require_certificate_validation`` must not attempt a TOFU pre-fetch."""
|
||||
# Pointing at a closed port: with system-trust the channel is created lazily
|
||||
# (no eager pre-fetch), so create_channel must succeed without connecting.
|
||||
options = ClientOptions(
|
||||
endpoint=f"127.0.0.1:{_free_port()}",
|
||||
require_certificate_validation=True,
|
||||
)
|
||||
channel = create_channel(options)
|
||||
assert isinstance(channel, grpc.aio.Channel)
|
||||
@@ -0,0 +1,22 @@
|
||||
# MSVC-only: bump the default 1 MB Windows stack to 8 MB. clap-derive builds
|
||||
# a large Command enum in this CLI (one variant per subcommand, each carrying
|
||||
# flag args); in debug builds the enum is materialized on the stack without
|
||||
# optimization and overflows the default Windows main-thread stack before
|
||||
# even reaching our code.
|
||||
#
|
||||
# The /STACK: link-arg goes into the PE header's IMAGE_OPTIONAL_HEADER.
|
||||
# SizeOfStackReserve at link time and applies to both debug and release
|
||||
# builds — release artifacts ship with the same 8 MB stack reservation. At
|
||||
# runtime the optimizer elides the enum from the stack frame, so release
|
||||
# builds would not overflow without this setting; it is kept on for them so
|
||||
# both build flavours produce binaries with identical stack metadata.
|
||||
#
|
||||
# `/STACK:` is an MSVC-linker (`link.exe` / `lld-link`) directive. The
|
||||
# `target_env = "msvc"` selector below scopes the rustflag to the MSVC
|
||||
# toolchain so `x86_64-pc-windows-gnu` (mingw) builds, which route link
|
||||
# args through the GNU linker and reject `/STACK:`, are unaffected.
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "link-arg=/STACK:8388608"]
|
||||
|
||||
[registries.dohertj2-gitea]
|
||||
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||
Generated
+69
-2
@@ -207,6 +207,22 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -574,7 +590,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "mxgw-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"futures-util",
|
||||
@@ -597,6 +613,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -796,6 +818,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
@@ -816,6 +850,38 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
@@ -1056,6 +1122,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"rustls-native-certs",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -1423,7 +1490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
|
||||
+17
-5
@@ -1,8 +1,17 @@
|
||||
[package]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
authors = ["Joseph Doherty"]
|
||||
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
documentation = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
readme = "README.md"
|
||||
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||
categories = ["api-bindings", "asynchronous"]
|
||||
publish = ["dohertj2-gitea"]
|
||||
build = "build.rs"
|
||||
|
||||
[workspace]
|
||||
@@ -11,8 +20,11 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
publish = false
|
||||
version = "0.1.1"
|
||||
authors = ["Joseph Doherty"]
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
publish = ["dohertj2-gitea"]
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
@@ -25,7 +37,7 @@ serde_json = "1.0.145"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-stream = { version = "0.1.17", features = ["net"] }
|
||||
tonic = { version = "0.13.1", features = ["transport", "tls-ring"] }
|
||||
tonic = { version = "0.13.1", features = ["transport", "tls-ring", "tls-native-roots"] }
|
||||
tonic-build = "0.13.1"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+95
-1
@@ -62,6 +62,8 @@ cargo run -p mxgw-cli -- register --session-id <session-id> --client-name mxgw-r
|
||||
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --json
|
||||
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
||||
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 1 --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
||||
```
|
||||
|
||||
@@ -74,6 +76,27 @@ types.
|
||||
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
||||
```
|
||||
|
||||
### TLS trust (pin-only)
|
||||
|
||||
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||
Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1
|
||||
exposes no public hook to inject a custom certificate verifier, so TLS over Rust
|
||||
cannot accept an *arbitrary* self-signed certificate. A TLS connection requires
|
||||
one of two trust paths:
|
||||
|
||||
- `--ca-file` / `ClientOptions::with_ca_file(...)` to pin a CA (export the
|
||||
gateway's self-signed certificate and pin it). This is the path for a
|
||||
self-signed gateway.
|
||||
- `--require-certificate-validation` / `with_require_certificate_validation(true)`
|
||||
to verify against the operating system's trust roots (`tls-native-roots`). This
|
||||
only succeeds for a certificate that chains to a root the host already trusts —
|
||||
i.e. a gateway fronted by a publicly- or enterprise-CA-issued certificate, not a
|
||||
bare self-signed one.
|
||||
|
||||
TLS with neither set fails `connect` with a clear, actionable error rather
|
||||
than accepting the certificate. See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
## Library Surface
|
||||
|
||||
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
||||
@@ -82,7 +105,10 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
|
||||
<api-key>` metadata to unary and streaming calls.
|
||||
|
||||
`GatewayClient` exposes raw generated calls through `open_session_raw`,
|
||||
`close_session_raw`, `invoke_raw`, `stream_events`, and `raw_client`. The
|
||||
`close_session_raw`, `invoke_raw`, `stream_events`, `query_active_alarms`,
|
||||
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
||||
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
||||
shares the gateway's central alarm monitor with every other client. The
|
||||
session helpers keep MXAccess handles visible:
|
||||
|
||||
```rust
|
||||
@@ -133,6 +159,50 @@ cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:500
|
||||
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||
```
|
||||
|
||||
### Browsing lazily
|
||||
|
||||
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
|
||||
time instead of paging the full hierarchy. Pass a default request for root
|
||||
objects; subsequent calls set `parent_gobject_id`, `parent_tag_name`, or
|
||||
`parent_contained_path`. Filter fields match `discover_hierarchy`. Each response
|
||||
pairs `children` with `child_has_children` so you know which nodes to expand. See
|
||||
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||
request and filter semantics.
|
||||
|
||||
```rust
|
||||
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;
|
||||
|
||||
let reply = galaxy.browse_children(BrowseChildrenRequest::default()).await?.into_inner();
|
||||
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
|
||||
println!("{} expand={}", child.tag_name, has_children);
|
||||
}
|
||||
```
|
||||
|
||||
#### High-level walker
|
||||
|
||||
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||
sibling pagination and the `child_has_children` hint for you:
|
||||
|
||||
```rust
|
||||
let mut client = GalaxyClient::connect(
|
||||
ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new(api_key)),
|
||||
).await?;
|
||||
let roots = client.browse(None).await?;
|
||||
for root in &roots {
|
||||
if root.has_children_hint() {
|
||||
root.expand().await?;
|
||||
}
|
||||
for child in root.children().await {
|
||||
let kind = if child.has_children_hint() { "has children" } else { "leaf" };
|
||||
println!("{} ({kind})", child.object().tag_name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`expand` is idempotent — calling it twice fires only one RPC,
|
||||
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||
`browse` again from the root.
|
||||
|
||||
### Watching deploy events
|
||||
|
||||
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
|
||||
@@ -187,3 +257,27 @@ cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --
|
||||
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||
- [Rust Client Detailed Design](./RustClientDesign.md)
|
||||
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
||||
|
||||
## Installing from the Gitea Cargo registry
|
||||
|
||||
The crate publishes to the internal Gitea Cargo registry. Register the
|
||||
registry once in your global `~/.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[registries.dohertj2-gitea]
|
||||
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||
```
|
||||
|
||||
Authentication: cargo reads credentials from `~/.cargo/credentials.toml`:
|
||||
|
||||
```toml
|
||||
[registries.dohertj2-gitea]
|
||||
token = "Bearer <your-gitea-token>"
|
||||
```
|
||||
|
||||
Then add the dependency:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
zb-mom-ww-mxgateway-client = { version = "0.1.1", registry = "dohertj2-gitea" }
|
||||
```
|
||||
|
||||
@@ -56,7 +56,23 @@ Expected dependencies:
|
||||
- `clap`
|
||||
- `serde`
|
||||
- `serde_json`
|
||||
- `tracing`
|
||||
|
||||
## Windows Build Notes
|
||||
|
||||
`clients/rust/.cargo/config.toml` carries an MSVC-scoped rustflag that bumps
|
||||
the default 1 MB Windows main-thread stack to 8 MB
|
||||
(`-C link-arg=/STACK:8388608`, under `cfg(all(windows, target_env = "msvc"))`).
|
||||
The setting is required because clap-derive materialises a large `Command`
|
||||
enum (one variant per CLI subcommand, each carrying its flag args) on the
|
||||
main task's stack in debug builds, before any user code runs; the default 1
|
||||
MB stack overflows during enum construction. The `/STACK:` link-arg writes
|
||||
into the PE header's `IMAGE_OPTIONAL_HEADER.SizeOfStackReserve` at link
|
||||
time, so both debug and release artifacts ship with the same 8 MB stack
|
||||
reservation. Release builds would not overflow without it (the optimizer
|
||||
elides the enum from the stack frame), but the setting is kept on for
|
||||
release too so both build flavours produce binaries with identical stack
|
||||
metadata. The MSVC-only selector keeps `x86_64-pc-windows-gnu` (mingw)
|
||||
builds unaffected, since the GNU linker rejects `/STACK:`.
|
||||
|
||||
## Library API
|
||||
|
||||
@@ -94,18 +110,126 @@ impl Session {
|
||||
pub async fn add_item(&self, server_handle: i32, item: &str) -> Result<i32, Error>;
|
||||
pub async fn add_item2(&self, server_handle: i32, item: &str, context: &str) -> Result<i32, Error>;
|
||||
pub async fn advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error>;
|
||||
pub async fn un_advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error>;
|
||||
pub async fn remove_item(&self, server_handle: i32, item_handle: i32) -> Result<(), Error>;
|
||||
pub async fn add_item_bulk(&self, server_handle: i32, tag_addresses: Vec<String>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn advise_item_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn remove_item_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn un_advise_item_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn subscribe_bulk(&self, server_handle: i32, tag_addresses: Vec<String>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn unsubscribe_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
|
||||
pub async fn read_bulk<S: AsRef<str>>(&self, server_handle: i32, tag_addresses: &[S], timeout_ms: u32) -> Result<Vec<BulkReadResult>, Error>;
|
||||
pub async fn write(&self, server_handle: i32, item_handle: i32, value: MxValue, user_id: i32) -> Result<(), Error>;
|
||||
pub async fn write2(&self, server_handle: i32, item_handle: i32, value: MxValue, timestamp_value: MxValue, user_id: i32) -> Result<(), Error>;
|
||||
pub async fn write_bulk(&self, server_handle: i32, entries: Vec<WriteBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write2_bulk(&self, server_handle: i32, entries: Vec<Write2BulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write_secured_bulk(&self, server_handle: i32, entries: Vec<WriteSecuredBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn write_secured2_bulk(&self, server_handle: i32, entries: Vec<WriteSecured2BulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
|
||||
pub async fn events(&self) -> Result<impl Stream<Item = Result<MxEvent, Error>>, Error>;
|
||||
pub async fn close(&self) -> Result<(), Error>;
|
||||
}
|
||||
```
|
||||
|
||||
The per-entry credentials and timestamps (`user_id`, `timestamp_value`,
|
||||
`current_user_id`, `verifier_user_id`) live on the `WriteBulkEntry` /
|
||||
`Write2BulkEntry` / `WriteSecuredBulkEntry` / `WriteSecured2BulkEntry`
|
||||
structs rather than as trailing positional arguments on the bulk-write
|
||||
helpers, matching the protobuf shapes in `mxaccess_gateway.proto`.
|
||||
`read_bulk` is generic over `AsRef<str>` so callers can pass `&[String]` or
|
||||
`&[&str]` without cloning at the call site (the cross-language bench-read-bulk
|
||||
hot loop relies on this).
|
||||
|
||||
The `session::next_correlation_id` helper is `pub` and re-exported at the
|
||||
crate root (`zb_mom_ww_mxgateway_client::next_correlation_id`); raw-RPC
|
||||
consumers like the `mxgw` CLI's `Ping`, `CloseSession`, `StreamAlarms`,
|
||||
`AcknowledgeAlarm`, and `BenchReadBulk` paths call it so every request
|
||||
carries a unique correlation id that gateway logs can tell apart from
|
||||
concurrent CLI smokes. The textual format is intentionally not part of the
|
||||
public contract.
|
||||
|
||||
## Alarms
|
||||
|
||||
`GatewayClient` exposes the gateway's session-less central alarm surface:
|
||||
|
||||
```rust
|
||||
pub type AlarmFeedStream = Pin<Box<dyn Stream<Item = Result<AlarmFeedMessage, Error>> + Send + 'static>>;
|
||||
|
||||
impl GatewayClient {
|
||||
pub async fn stream_alarms(&self, request: StreamAlarmsRequest) -> Result<AlarmFeedStream, Error>;
|
||||
pub async fn acknowledge_alarm(&self, request: AcknowledgeAlarmRequest) -> Result<AcknowledgeAlarmReply, Error>;
|
||||
}
|
||||
```
|
||||
|
||||
`stream_alarms` opens with one `active_alarm` per currently-active alarm
|
||||
(the ConditionRefresh snapshot), then a single `snapshot_complete`, then a
|
||||
`transition` for every subsequent raise / acknowledge / clear. A fourth
|
||||
`provider_status` oneof case (`AlarmProviderStatus`: `mode`, `degraded`,
|
||||
`reason`, `since`) is emitted once on stream open and again on every
|
||||
failover/failback so late joiners learn the current alarm-provider mode.
|
||||
The CLI renders all four cases in both its one-line summary and its
|
||||
protobuf-JSON output (`alarm_feed_message_summary` /
|
||||
`alarm_feed_message_to_json`). The feed is served by the gateway's always-on
|
||||
alarm monitor — no worker session is opened — so any number of clients may
|
||||
attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
|
||||
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
|
||||
|
||||
## Galaxy Repository
|
||||
|
||||
`GalaxyClient` is a session-less metadata client (requires the
|
||||
`metadata:read` API-key scope). Alongside `test_connection`,
|
||||
`get_last_deploy_time`, `discover_hierarchy`, and `watch_deploy_events`, it
|
||||
exposes a lazy hierarchy walker built on the `BrowseChildren` RPC:
|
||||
|
||||
```rust
|
||||
impl GalaxyClient {
|
||||
pub async fn browse(&mut self, options: Option<BrowseChildrenOptions>) -> Result<Vec<LazyBrowseNode>, Error>;
|
||||
pub async fn browse_children_raw(&mut self, request: BrowseChildrenRequest) -> Result<BrowseChildrenReply, Error>;
|
||||
}
|
||||
|
||||
pub struct BrowseChildrenOptions {
|
||||
pub category_ids: Vec<i32>,
|
||||
pub template_chain_contains: Vec<String>,
|
||||
pub tag_name_glob: Option<String>,
|
||||
pub include_attributes: Option<bool>,
|
||||
pub alarm_bearing_only: bool,
|
||||
pub historized_only: bool,
|
||||
}
|
||||
|
||||
impl LazyBrowseNode {
|
||||
pub fn object(&self) -> &GalaxyObject;
|
||||
pub fn has_children_hint(&self) -> bool;
|
||||
pub async fn children(&self) -> Vec<LazyBrowseNode>;
|
||||
pub async fn is_expanded(&self) -> bool;
|
||||
pub async fn expand(&self) -> Result<(), Error>;
|
||||
}
|
||||
```
|
||||
|
||||
- `browse(options)` returns the root objects as `LazyBrowseNode`s. The
|
||||
supplied `BrowseChildrenOptions` filter is captured and reused when any
|
||||
returned node is expanded, so a single filter set scopes the entire walk.
|
||||
- `BrowseChildrenOptions` mirrors the request-level filters on the wire and
|
||||
combines them with **AND**: a child appears only when it satisfies every
|
||||
populated criterion (`category_ids` membership, every
|
||||
`template_chain_contains` substring, the `tag_name_glob`, plus the
|
||||
`alarm_bearing_only` / `historized_only` flags). `include_attributes` is a
|
||||
tri-state (`None` = server default). Empty/`None` fields impose no
|
||||
restriction. See
|
||||
[Galaxy Repository — BrowseChildren](../../docs/GalaxyRepository.md#browsechildren)
|
||||
for the wire-level semantics.
|
||||
- `LazyBrowseNode` is cheap to clone — clones share state through an internal
|
||||
`Arc`, so expanding one clone makes the children visible to every clone.
|
||||
`has_children_hint()` exposes the server's `child_has_children` hint so a UI
|
||||
can draw an expand affordance without issuing an RPC. `expand()` is
|
||||
idempotent: the first call issues a paged `BrowseChildren` walk (page size
|
||||
500) under an async mutex held across the await, sets the `is_expanded`
|
||||
flag, and caches the children; subsequent calls are no-ops and re-hit
|
||||
nothing. The internal paged loop guards against a server returning a
|
||||
repeated `next_page_token` by failing with `Error::InvalidArgument` rather
|
||||
than looping forever.
|
||||
- `browse_children_raw` issues a single `BrowseChildren` RPC and returns the
|
||||
raw reply for callers that want to drive paging themselves.
|
||||
|
||||
## Authentication
|
||||
|
||||
Use a `tonic` interceptor or request extension layer to add:
|
||||
@@ -126,6 +250,32 @@ Support:
|
||||
- custom CA file,
|
||||
- domain override.
|
||||
|
||||
### Trust posture (pin-only)
|
||||
|
||||
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||
PKI). Rust is the **exception** to the lenient-by-default posture the other
|
||||
clients use: tonic 0.13.1 exposes no public hook to inject a custom certificate
|
||||
verifier, so the Rust client cannot accept an arbitrary certificate. TLS over the
|
||||
Rust client is therefore **pin-only** — it requires either:
|
||||
|
||||
- `ClientOptions::with_ca_file(...)` to pin a CA (the supported path for the
|
||||
gateway's self-signed certificate; export the certificate and pin it), or
|
||||
- `ClientOptions::with_require_certificate_validation(true)` to verify against the
|
||||
operating system's trust roots. This enables the `tonic` `tls-native-roots`
|
||||
feature and calls `ClientTlsConfig::with_native_roots()`, so the handshake
|
||||
validates a certificate that chains to a root the host already trusts. It does
|
||||
**not** accept a bare self-signed gateway certificate — that still needs
|
||||
`with_ca_file`.
|
||||
|
||||
`build_tls_config` computes the trust posture with the pure `tls_trust_decision`
|
||||
helper (`None` / `PinnedCa` / `SystemRoots` / `RejectNoCa`) so the posture is
|
||||
unit-testable without a live handshake. With TLS enabled (`with_plaintext(false)`),
|
||||
no pinned CA, and certificate validation not required (`RejectNoCa`),
|
||||
`GatewayClient::connect` rejects the connection with a clear, actionable error
|
||||
pointing at `with_ca_file` / `require_certificate_validation` rather than building
|
||||
a config with zero trust anchors. The CLI exposes `--ca-file` and
|
||||
`--require-certificate-validation`.
|
||||
|
||||
## Streaming
|
||||
|
||||
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
|
||||
@@ -140,19 +290,31 @@ Use `thiserror`:
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
InvalidEndpoint { endpoint: String, detail: String },
|
||||
InvalidArgument { name: String, detail: String },
|
||||
Transport(tonic::transport::Error),
|
||||
Status(tonic::Status),
|
||||
Authentication(String),
|
||||
Authorization(String),
|
||||
Session(SessionError),
|
||||
Worker(WorkerError),
|
||||
Command(CommandError),
|
||||
MxAccess(MxAccessError),
|
||||
Timeout,
|
||||
Cancelled,
|
||||
Authentication { message: String, status: Box<tonic::Status> },
|
||||
Authorization { message: String, status: Box<tonic::Status> },
|
||||
Timeout { message: String, status: Box<tonic::Status> },
|
||||
Cancelled { message: String, status: Box<tonic::Status> },
|
||||
Unavailable { message: String, status: Box<tonic::Status> },
|
||||
Status(Box<tonic::Status>),
|
||||
Command(Box<CommandError>),
|
||||
ProtocolStatus { operation: &'static str, code: ProtocolStatusCode, message: String },
|
||||
MalformedReply { detail: String },
|
||||
}
|
||||
```
|
||||
|
||||
- `Unavailable` classifies `Code::Unavailable` / `Code::ResourceExhausted`
|
||||
so callers can distinguish transient failures from permanent ones.
|
||||
- `MalformedReply` surfaces the rare case where the gateway returned a
|
||||
protocol-level `Ok` envelope but the typed payload arm was missing or did
|
||||
not match the command kind (e.g. an `AddItemReply` body on a `WriteBulk`
|
||||
reply). This is distinct from `ProtocolStatus` because the protocol-level
|
||||
envelope itself succeeded; the corruption is in the payload shape.
|
||||
- `InvalidEndpoint` is raised before any RPC dispatch when the endpoint URL
|
||||
or CA file fails to parse / load.
|
||||
|
||||
Preserve raw command replies in `CommandError` where applicable.
|
||||
|
||||
## Test CLI
|
||||
@@ -165,11 +327,39 @@ Commands:
|
||||
|
||||
```text
|
||||
mxgw version
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw ping
|
||||
mxgw open-session
|
||||
mxgw close-session --session-id <id>
|
||||
mxgw register --session-id <id>
|
||||
mxgw add-item --session-id <id> --server-handle <h> --item <tag>
|
||||
mxgw advise --session-id <id> --server-handle <h> --item-handle <h>
|
||||
mxgw subscribe-bulk --session-id <id> --server-handle <h> --items <csv>
|
||||
mxgw unsubscribe-bulk --session-id <id> --server-handle <h> --item-handles <csv>
|
||||
mxgw read-bulk --session-id <id> --server-handle <h> --items <csv> [--timeout-ms <ms>]
|
||||
mxgw write --session-id <id> --server-handle 1 --item-handle 1 --value-type int32 --value 123
|
||||
mxgw write2 --session-id <id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --timestamp <iso>
|
||||
mxgw write-bulk --session-id <id> --server-handle <h> --item-handles <csv> --value-type <t> --values <csv>
|
||||
mxgw write2-bulk ...
|
||||
mxgw write-secured-bulk ...
|
||||
mxgw write-secured2-bulk ...
|
||||
mxgw stream-events --session-id <id> --json
|
||||
mxgw write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123
|
||||
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
|
||||
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw batch
|
||||
mxgw galaxy {test-connection,last-deploy-time,discover-hierarchy,watch}
|
||||
```
|
||||
|
||||
`batch` reads commands from stdin one per line and dispatches each through
|
||||
the normal subcommand path; the loop terminates only on stdin EOF (blank
|
||||
lines log an empty-EOR-bracketed result and continue) so accidental empty
|
||||
lines from the PowerShell e2e harness do not silently end the session.
|
||||
`bench-read-bulk` opens its own session, subscribes to `--bulk-size` tags so
|
||||
the worker's value cache populates from OnDataChange events, hammers
|
||||
`read_bulk` in a tight loop for `--duration-seconds`, and emits the
|
||||
cross-language JSON shape that `scripts/bench-read-bulk.ps1` collates.
|
||||
|
||||
JSON output should use `serde_json`.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "mxgw-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "mxgw"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+45
-20
@@ -6,21 +6,20 @@
|
||||
//! code should prefer [`GatewayClient::open_session`] and the [`Session`]
|
||||
//! handle it returns, rather than the `*_raw` methods.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tonic::codegen::InterceptedService;
|
||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
use crate::auth::AuthInterceptor;
|
||||
use crate::error::{ensure_command_success, ensure_protocol_success, Error};
|
||||
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, CloseSessionReply,
|
||||
CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent, OpenSessionReply,
|
||||
OpenSessionRequest, QueryActiveAlarmsRequest, StreamEventsRequest,
|
||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AlarmFeedMessage,
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
|
||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
||||
StreamEventsRequest,
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
use crate::options::{build_tls_config, ClientOptions};
|
||||
use crate::session::Session;
|
||||
|
||||
/// Generated gateway client wrapped in the auth interceptor that
|
||||
@@ -40,6 +39,13 @@ pub type ActiveAlarmStream = std::pin::Pin<
|
||||
Box<dyn futures_core::Stream<Item = Result<ActiveAlarmSnapshot, Error>> + Send + 'static>,
|
||||
>;
|
||||
|
||||
/// Pinned, boxed [`AlarmFeedMessage`] stream returned by
|
||||
/// [`GatewayClient::stream_alarms`]. Errors are pre-mapped from
|
||||
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
|
||||
pub type AlarmFeedStream = std::pin::Pin<
|
||||
Box<dyn futures_core::Stream<Item = Result<AlarmFeedMessage, Error>> + Send + 'static>,
|
||||
>;
|
||||
|
||||
/// Thin async wrapper around the generated gateway client.
|
||||
///
|
||||
/// The wrapper is `Clone`: every clone shares the underlying tonic channel
|
||||
@@ -70,18 +76,7 @@ impl GatewayClient {
|
||||
})?;
|
||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||
|
||||
if !options.plaintext() {
|
||||
let mut tls = ClientTlsConfig::new();
|
||||
if let Some(server_name) = options.server_name_override() {
|
||||
tls = tls.domain_name(server_name.to_owned());
|
||||
}
|
||||
if let Some(ca_file) = options.ca_file() {
|
||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||
})?;
|
||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||
}
|
||||
if let Some(tls) = build_tls_config(&options)? {
|
||||
endpoint = endpoint.tls_config(tls)?;
|
||||
}
|
||||
|
||||
@@ -219,7 +214,9 @@ impl GatewayClient {
|
||||
request: AcknowledgeAlarmRequest,
|
||||
) -> Result<AcknowledgeAlarmReply, Error> {
|
||||
let mut client = self.inner.clone();
|
||||
let response = client.acknowledge_alarm(self.unary_request(request)).await?;
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
@@ -252,6 +249,34 @@ impl GatewayClient {
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
/// Attach to the gateway's central `StreamAlarms` feed.
|
||||
///
|
||||
/// The returned [`AlarmFeedStream`] opens with one [`AlarmFeedMessage`]
|
||||
/// per currently-active alarm (the ConditionRefresh snapshot), then a
|
||||
/// single `snapshot_complete`, 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. Dropping the stream cancels the gRPC call cooperatively.
|
||||
/// Optional alarm-reference prefix scoping (`request.alarm_filter_prefix`)
|
||||
/// limits the stream to a sub-tree.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the `tonic::Status` mapped through [`Error::from`] if the
|
||||
/// server rejects the request.
|
||||
pub async fn stream_alarms(
|
||||
&self,
|
||||
request: StreamAlarmsRequest,
|
||||
) -> Result<AlarmFeedStream, Error> {
|
||||
let mut client = self.inner.clone();
|
||||
let response = client.stream_alarms(self.stream_request(request)).await?;
|
||||
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
||||
result.map_err(Error::from)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
fn unary_request<T>(&self, message: T) -> Request<T> {
|
||||
let mut request = Request::new(message);
|
||||
request.set_timeout(self.call_timeout);
|
||||
|
||||
@@ -106,6 +106,27 @@ pub enum Error {
|
||||
/// Detail message from the server.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Gateway returned an `Ok` protocol status but the reply lacked the
|
||||
/// expected typed payload (or carried the wrong payload arm). Distinct
|
||||
/// from [`Error::ProtocolStatus`] because the protocol-level envelope
|
||||
/// itself succeeded — the corruption is in the payload shape.
|
||||
#[error("gateway returned a malformed reply: {detail}")]
|
||||
MalformedReply {
|
||||
/// Human-readable description of what was missing or mismatched.
|
||||
detail: String,
|
||||
},
|
||||
|
||||
/// Server returned `Unavailable` or `ResourceExhausted` — classify
|
||||
/// transient failures separately from the catch-all [`Error::Status`].
|
||||
#[error("gateway unavailable: {message}")]
|
||||
Unavailable {
|
||||
/// Redacted server-supplied detail message.
|
||||
message: String,
|
||||
/// Original `tonic::Status`.
|
||||
#[source]
|
||||
status: Box<tonic::Status>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Wrapper around an [`MxCommandReply`] whose `protocol_status` reported a
|
||||
@@ -174,6 +195,10 @@ impl From<tonic::Status> for Error {
|
||||
message,
|
||||
status: Box::new(status),
|
||||
},
|
||||
Code::Unavailable | Code::ResourceExhausted => Self::Unavailable {
|
||||
message,
|
||||
status: Box::new(status),
|
||||
},
|
||||
_ => Self::Status(Box::new(status)),
|
||||
}
|
||||
}
|
||||
|
||||
+540
-21
@@ -5,23 +5,143 @@
|
||||
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are
|
||||
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
||||
|
||||
use std::fs;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use prost_types::Timestamp;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tonic::codegen::InterceptedService;
|
||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
use crate::auth::AuthInterceptor;
|
||||
use crate::error::Error;
|
||||
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
|
||||
use crate::generated::galaxy_repository::v1::{
|
||||
DeployEvent, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest,
|
||||
TestConnectionRequest, WatchDeployEventsRequest,
|
||||
browse_children_request, BrowseChildrenReply, BrowseChildrenRequest, DeployEvent,
|
||||
DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
|
||||
WatchDeployEventsRequest,
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
use crate::options::{build_tls_config, ClientOptions};
|
||||
|
||||
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
||||
const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500;
|
||||
|
||||
/// Optional filter set forwarded to `GalaxyRepository.BrowseChildren`.
|
||||
///
|
||||
/// Mirrors the request-level filters on the wire: combined with AND so a child
|
||||
/// only appears when it satisfies every populated criterion. Construct via
|
||||
/// [`BrowseChildrenOptions::default`] and tweak the fields you care about.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BrowseChildrenOptions {
|
||||
/// Restrict to objects whose `category_id` matches one of the supplied
|
||||
/// Galaxy category identifiers. Empty means "no restriction".
|
||||
pub category_ids: Vec<i32>,
|
||||
/// Restrict to objects whose template chain contains every supplied
|
||||
/// template name (case-sensitive substring match on each entry).
|
||||
pub template_chain_contains: Vec<String>,
|
||||
/// Restrict to objects whose tag name matches the supplied glob (SQL
|
||||
/// `LIKE`-style on the server). `None` means "no glob filter".
|
||||
pub tag_name_glob: Option<String>,
|
||||
/// Optional tri-state hint for whether to populate `GalaxyObject.attributes`
|
||||
/// on returned children. `None` falls back to the server default.
|
||||
pub include_attributes: Option<bool>,
|
||||
/// When `true`, only return children that own at least one alarm-bearing
|
||||
/// attribute (matches `DiscoverHierarchy` semantics).
|
||||
pub alarm_bearing_only: bool,
|
||||
/// When `true`, only return children that own at least one historized
|
||||
/// attribute (matches `DiscoverHierarchy` semantics).
|
||||
pub historized_only: bool,
|
||||
}
|
||||
|
||||
/// Lazy hierarchy node used by the walker built on top of `BrowseChildren`.
|
||||
///
|
||||
/// A node owns its [`GalaxyObject`], a hint as to whether the server believes
|
||||
/// it has at least one matching descendant under the active filter set, and an
|
||||
/// internal `expanded` flag protected by an async mutex. Calling [`expand`]
|
||||
/// the first time issues a paged `BrowseChildren` RPC; subsequent calls are
|
||||
/// no-ops so callers can poll without re-hitting the server.
|
||||
///
|
||||
/// `LazyBrowseNode` is cheap to clone — clones share state through an
|
||||
/// internal `Arc`, so expanding one clone makes the children visible to every
|
||||
/// other clone.
|
||||
///
|
||||
/// [`expand`]: LazyBrowseNode::expand
|
||||
pub struct LazyBrowseNode {
|
||||
inner: Arc<LazyBrowseNodeInner>,
|
||||
}
|
||||
|
||||
impl Clone for LazyBrowseNode {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LazyBrowseNodeInner {
|
||||
client: GalaxyClient,
|
||||
object: GalaxyObject,
|
||||
has_children_hint: bool,
|
||||
options: BrowseChildrenOptions,
|
||||
state: AsyncMutex<LazyBrowseNodeState>,
|
||||
}
|
||||
|
||||
struct LazyBrowseNodeState {
|
||||
children: Vec<LazyBrowseNode>,
|
||||
is_expanded: bool,
|
||||
}
|
||||
|
||||
impl LazyBrowseNode {
|
||||
/// Borrow the [`GalaxyObject`] returned by the server for this node.
|
||||
pub fn object(&self) -> &GalaxyObject {
|
||||
&self.inner.object
|
||||
}
|
||||
|
||||
/// Server-supplied hint: `true` when the child likely has at least one
|
||||
/// further matching descendant. Useful to decide whether a UI should draw
|
||||
/// an expand triangle without issuing the RPC up front.
|
||||
pub fn has_children_hint(&self) -> bool {
|
||||
self.inner.has_children_hint
|
||||
}
|
||||
|
||||
/// Snapshot of the currently-known children. Empty until [`expand`] has
|
||||
/// run at least once.
|
||||
///
|
||||
/// [`expand`]: LazyBrowseNode::expand
|
||||
pub async fn children(&self) -> Vec<LazyBrowseNode> {
|
||||
self.inner.state.lock().await.children.clone()
|
||||
}
|
||||
|
||||
/// Returns `true` once [`expand`] has populated this node's children.
|
||||
///
|
||||
/// [`expand`]: LazyBrowseNode::expand
|
||||
pub async fn is_expanded(&self) -> bool {
|
||||
self.inner.state.lock().await.is_expanded
|
||||
}
|
||||
|
||||
/// Populate this node's children by issuing a paged `BrowseChildren` RPC.
|
||||
/// Subsequent calls are no-ops — the cached children stay in place and no
|
||||
/// additional RPC is issued.
|
||||
pub async fn expand(&self) -> Result<(), Error> {
|
||||
let mut state = self.inner.state.lock().await;
|
||||
if state.is_expanded {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut client = self.inner.client.clone();
|
||||
let new_children = client
|
||||
.browse_children_inner(
|
||||
Some(self.inner.object.gobject_id),
|
||||
self.inner.options.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
state.children = new_children;
|
||||
state.is_expanded = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience alias for the generated Galaxy client wrapped in the
|
||||
/// authentication interceptor.
|
||||
@@ -62,18 +182,7 @@ impl GalaxyClient {
|
||||
})?;
|
||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||
|
||||
if !options.plaintext() {
|
||||
let mut tls = ClientTlsConfig::new();
|
||||
if let Some(server_name) = options.server_name_override() {
|
||||
tls = tls.domain_name(server_name.to_owned());
|
||||
}
|
||||
if let Some(ca_file) = options.ca_file() {
|
||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||
})?;
|
||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||
}
|
||||
if let Some(tls) = build_tls_config(&options)? {
|
||||
endpoint = endpoint.tls_config(tls)?;
|
||||
}
|
||||
|
||||
@@ -172,6 +281,99 @@ impl GalaxyClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse the top-level (root) objects of the hierarchy as
|
||||
/// [`LazyBrowseNode`] instances. Pass [`BrowseChildrenOptions`] to
|
||||
/// restrict the result set; the same filter is reused when callers expand
|
||||
/// any returned node.
|
||||
pub async fn browse(
|
||||
&mut self,
|
||||
options: Option<BrowseChildrenOptions>,
|
||||
) -> Result<Vec<LazyBrowseNode>, Error> {
|
||||
let effective = options.unwrap_or_default();
|
||||
self.browse_children_inner(None, effective).await
|
||||
}
|
||||
|
||||
/// Issue a single `BrowseChildren` RPC and return the raw reply. Callers
|
||||
/// that want to drive paging themselves (or inspect the cache sequence)
|
||||
/// use this; high-level walking goes through [`browse`] and
|
||||
/// [`LazyBrowseNode::expand`].
|
||||
///
|
||||
/// [`browse`]: GalaxyClient::browse
|
||||
pub async fn browse_children_raw(
|
||||
&mut self,
|
||||
request: BrowseChildrenRequest,
|
||||
) -> Result<BrowseChildrenReply, Error> {
|
||||
let response = self
|
||||
.inner
|
||||
.browse_children(self.unary_request(request))
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
}
|
||||
|
||||
pub(crate) async fn browse_children_inner(
|
||||
&mut self,
|
||||
parent_gobject_id: Option<i32>,
|
||||
options: BrowseChildrenOptions,
|
||||
) -> Result<Vec<LazyBrowseNode>, Error> {
|
||||
let mut nodes = Vec::new();
|
||||
let mut page_token = String::new();
|
||||
let mut seen_page_tokens: HashSet<String> = HashSet::new();
|
||||
loop {
|
||||
let parent = parent_gobject_id.map(browse_children_request::Parent::ParentGobjectId);
|
||||
let request = BrowseChildrenRequest {
|
||||
page_size: BROWSE_CHILDREN_PAGE_SIZE,
|
||||
page_token: page_token.clone(),
|
||||
category_ids: options.category_ids.clone(),
|
||||
template_chain_contains: options.template_chain_contains.clone(),
|
||||
tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(),
|
||||
include_attributes: options.include_attributes,
|
||||
alarm_bearing_only: options.alarm_bearing_only,
|
||||
historized_only: options.historized_only,
|
||||
parent,
|
||||
};
|
||||
|
||||
let reply = self.browse_children_raw(request).await?;
|
||||
let hints = reply.child_has_children;
|
||||
for (index, object) in reply.children.into_iter().enumerate() {
|
||||
let hint = hints.get(index).copied().unwrap_or(false);
|
||||
nodes.push(self.make_lazy_node(object, hint, options.clone()));
|
||||
}
|
||||
|
||||
page_token = reply.next_page_token;
|
||||
if page_token.is_empty() {
|
||||
return Ok(nodes);
|
||||
}
|
||||
if !seen_page_tokens.insert(page_token.clone()) {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "page_token".to_owned(),
|
||||
detail: format!(
|
||||
"galaxy browse children returned repeated page token `{page_token}`"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_lazy_node(
|
||||
&self,
|
||||
object: GalaxyObject,
|
||||
has_children_hint: bool,
|
||||
options: BrowseChildrenOptions,
|
||||
) -> LazyBrowseNode {
|
||||
LazyBrowseNode {
|
||||
inner: Arc::new(LazyBrowseNodeInner {
|
||||
client: self.clone(),
|
||||
object,
|
||||
has_children_hint,
|
||||
options,
|
||||
state: AsyncMutex::new(LazyBrowseNodeState {
|
||||
children: Vec::new(),
|
||||
is_expanded: false,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the server-streamed deploy-event feed.
|
||||
///
|
||||
/// The server emits a bootstrap event describing the current cache state
|
||||
@@ -234,9 +436,10 @@ mod tests {
|
||||
GalaxyRepository, GalaxyRepositoryServer,
|
||||
};
|
||||
use crate::generated::galaxy_repository::v1::{
|
||||
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute,
|
||||
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply,
|
||||
TestConnectionRequest, WatchDeployEventsRequest,
|
||||
BrowseChildrenReply, BrowseChildrenRequest, DeployEvent, DiscoverHierarchyReply,
|
||||
DiscoverHierarchyRequest, GalaxyAttribute, GalaxyObject, GetLastDeployTimeReply,
|
||||
GetLastDeployTimeRequest, TestConnectionReply, TestConnectionRequest,
|
||||
WatchDeployEventsRequest,
|
||||
};
|
||||
|
||||
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
|
||||
@@ -249,6 +452,9 @@ mod tests {
|
||||
objects: Mutex<Vec<GalaxyObject>>,
|
||||
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
|
||||
discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>,
|
||||
browse_children_calls: Mutex<Vec<BrowseChildrenRequest>>,
|
||||
browse_children_replies: Mutex<std::collections::VecDeque<BrowseChildrenReply>>,
|
||||
browse_children_errors: Mutex<Vec<Status>>,
|
||||
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
|
||||
watch_events: Mutex<Vec<DeployEvent>>,
|
||||
watch_senders: Mutex<Vec<DeployEventTx>>,
|
||||
@@ -279,7 +485,7 @@ mod tests {
|
||||
_request: Request<GetLastDeployTimeRequest>,
|
||||
) -> Result<Response<GetLastDeployTimeReply>, Status> {
|
||||
let present = *self.state.present.lock().unwrap();
|
||||
let time = self.state.last_deploy.lock().unwrap().clone();
|
||||
let time = *self.state.last_deploy.lock().unwrap();
|
||||
Ok(Response::new(GetLastDeployTimeReply {
|
||||
present,
|
||||
time_of_last_deploy: time,
|
||||
@@ -306,6 +512,28 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn browse_children(
|
||||
&self,
|
||||
request: Request<BrowseChildrenRequest>,
|
||||
) -> Result<Response<BrowseChildrenReply>, Status> {
|
||||
self.state
|
||||
.browse_children_calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.into_inner());
|
||||
if let Some(error) = self.state.browse_children_errors.lock().unwrap().pop() {
|
||||
return Err(error);
|
||||
}
|
||||
let reply = self
|
||||
.state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_default();
|
||||
Ok(Response::new(reply))
|
||||
}
|
||||
|
||||
type WatchDeployEventsStream =
|
||||
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
|
||||
|
||||
@@ -695,4 +923,295 @@ mod tests {
|
||||
"drop signal channel closed unexpectedly"
|
||||
);
|
||||
}
|
||||
|
||||
fn browse_obj(gid: i32, tag: &str, is_area: bool) -> GalaxyObject {
|
||||
GalaxyObject {
|
||||
gobject_id: gid,
|
||||
tag_name: tag.to_owned(),
|
||||
contained_name: String::new(),
|
||||
browse_name: tag.to_owned(),
|
||||
parent_gobject_id: 0,
|
||||
is_area,
|
||||
category_id: 0,
|
||||
hosted_by_gobject_id: 0,
|
||||
template_chain: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_browse_reply(
|
||||
children: Vec<GalaxyObject>,
|
||||
child_has_children: Vec<bool>,
|
||||
cache_sequence: u64,
|
||||
) -> BrowseChildrenReply {
|
||||
BrowseChildrenReply {
|
||||
total_child_count: children.len() as i32,
|
||||
cache_sequence,
|
||||
children,
|
||||
child_has_children,
|
||||
next_page_token: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browse_no_parent_returns_roots() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(1, "Area_A", true), browse_obj(2, "Area_B", true)],
|
||||
vec![true, false],
|
||||
7,
|
||||
));
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let roots = client.browse(None).await.unwrap();
|
||||
|
||||
assert_eq!(roots.len(), 2);
|
||||
assert_eq!(roots[0].object().tag_name, "Area_A");
|
||||
assert!(roots[0].has_children_hint());
|
||||
assert_eq!(roots[1].object().tag_name, "Area_B");
|
||||
assert!(!roots[1].has_children_hint());
|
||||
|
||||
let calls = state.browse_children_calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert!(
|
||||
calls[0].parent.is_none(),
|
||||
"root browse must send an empty parent oneof, got {:?}",
|
||||
calls[0].parent
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browse_expand_populates_children_and_marks_expanded() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
// First call: roots.
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(10, "Area_A", true)],
|
||||
vec![true],
|
||||
1,
|
||||
));
|
||||
// Second call: children of gobject 10.
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(11, "Receiver_1", false)],
|
||||
vec![false],
|
||||
1,
|
||||
));
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let roots = client.browse(None).await.unwrap();
|
||||
let root = roots.into_iter().next().expect("at least one root");
|
||||
assert!(!root.is_expanded().await);
|
||||
|
||||
root.expand().await.unwrap();
|
||||
|
||||
assert!(root.is_expanded().await);
|
||||
let children = root.children().await;
|
||||
assert_eq!(children.len(), 1);
|
||||
assert_eq!(children[0].object().tag_name, "Receiver_1");
|
||||
|
||||
let calls = state.browse_children_calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 2);
|
||||
let expand_call = &calls[1];
|
||||
match expand_call.parent.as_ref().expect("expand sends parent") {
|
||||
browse_children_request::Parent::ParentGobjectId(id) => assert_eq!(*id, 10),
|
||||
other => panic!("expected ParentGobjectId variant, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browse_expand_idempotent_no_second_rpc() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(20, "Area_X", true)],
|
||||
vec![true],
|
||||
1,
|
||||
));
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(21, "Leaf", false)],
|
||||
vec![false],
|
||||
1,
|
||||
));
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let roots = client.browse(None).await.unwrap();
|
||||
let root = roots.into_iter().next().unwrap();
|
||||
root.expand().await.unwrap();
|
||||
let after_first = state.browse_children_calls.lock().unwrap().len();
|
||||
|
||||
// Calling expand a second time must NOT issue a new RPC.
|
||||
root.expand().await.unwrap();
|
||||
|
||||
let after_second = state.browse_children_calls.lock().unwrap().len();
|
||||
assert_eq!(
|
||||
after_first, after_second,
|
||||
"expand should be idempotent — no extra RPC the second time"
|
||||
);
|
||||
assert_eq!(root.children().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browse_expand_unknown_parent_returns_not_found_error() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
// Root browse succeeds.
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(99, "GhostArea", true)],
|
||||
vec![true],
|
||||
1,
|
||||
));
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let roots = client.browse(None).await.unwrap();
|
||||
let root = roots.into_iter().next().unwrap();
|
||||
|
||||
// Seed the NotFound only AFTER the root call so the FakeGalaxy's
|
||||
// error stack doesn't intercept the initial browse.
|
||||
state
|
||||
.browse_children_errors
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(Status::not_found("parent gobject 99 not present in cache"));
|
||||
|
||||
let error = root.expand().await.unwrap_err();
|
||||
|
||||
match &error {
|
||||
Error::Status(status) => {
|
||||
assert_eq!(status.code(), tonic::Code::NotFound);
|
||||
}
|
||||
other => panic!("expected Error::Status(NotFound), got {other:?}"),
|
||||
}
|
||||
// Failed expand must NOT mark the node as expanded — caller can retry.
|
||||
assert!(!root.is_expanded().await);
|
||||
assert!(root.children().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browse_expand_multi_page_gathers_all_pages() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
// First reply: roots.
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(30, "Plant", true)],
|
||||
vec![true],
|
||||
5,
|
||||
));
|
||||
// Second reply: page 1 of children, with a next_page_token.
|
||||
let mut page_one = build_browse_reply(
|
||||
vec![
|
||||
browse_obj(31, "Child_A", false),
|
||||
browse_obj(32, "Child_B", false),
|
||||
],
|
||||
vec![false, false],
|
||||
5,
|
||||
);
|
||||
page_one.next_page_token = "cursor-2".to_owned();
|
||||
page_one.total_child_count = 3;
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(page_one);
|
||||
// Third reply: page 2 of children, with no next page.
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(
|
||||
vec![browse_obj(33, "Child_C", false)],
|
||||
vec![false],
|
||||
5,
|
||||
));
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let roots = client.browse(None).await.unwrap();
|
||||
let root = roots.into_iter().next().unwrap();
|
||||
root.expand().await.unwrap();
|
||||
|
||||
let children = root.children().await;
|
||||
assert_eq!(children.len(), 3);
|
||||
assert_eq!(children[0].object().tag_name, "Child_A");
|
||||
assert_eq!(children[1].object().tag_name, "Child_B");
|
||||
assert_eq!(children[2].object().tag_name, "Child_C");
|
||||
|
||||
let calls = state.browse_children_calls.lock().unwrap();
|
||||
// 1 root call + 2 paged expand calls = 3 total.
|
||||
assert_eq!(calls.len(), 3);
|
||||
assert_eq!(calls[1].page_token, "");
|
||||
assert_eq!(calls[2].page_token, "cursor-2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browse_with_filter_forwards_to_request() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
state
|
||||
.browse_children_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(build_browse_reply(Vec::new(), Vec::new(), 1));
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let options = BrowseChildrenOptions {
|
||||
category_ids: vec![3, 5],
|
||||
template_chain_contains: vec!["$DelmiaReceiver".to_owned()],
|
||||
tag_name_glob: Some("Recv_*".to_owned()),
|
||||
include_attributes: Some(true),
|
||||
alarm_bearing_only: true,
|
||||
historized_only: false,
|
||||
};
|
||||
|
||||
let _ = client.browse(Some(options)).await.unwrap();
|
||||
|
||||
let calls = state.browse_children_calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
let req = &calls[0];
|
||||
assert_eq!(req.category_ids, vec![3, 5]);
|
||||
assert_eq!(req.template_chain_contains, vec!["$DelmiaReceiver"]);
|
||||
assert_eq!(req.tag_name_glob, "Recv_*");
|
||||
assert_eq!(req.include_attributes, Some(true));
|
||||
assert!(req.alarm_bearing_only);
|
||||
assert!(!req.historized_only);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub mod version;
|
||||
#[doc(inline)]
|
||||
pub use auth::{ApiKey, AuthInterceptor};
|
||||
#[doc(inline)]
|
||||
pub use client::{EventStream, GatewayClient};
|
||||
pub use client::{AlarmFeedStream, EventStream, GatewayClient};
|
||||
#[doc(inline)]
|
||||
pub use error::{CommandError, Error};
|
||||
#[doc(inline)]
|
||||
@@ -32,7 +32,7 @@ pub use galaxy::{DeployEventStream, GalaxyClient};
|
||||
#[doc(inline)]
|
||||
pub use options::ClientOptions;
|
||||
#[doc(inline)]
|
||||
pub use session::Session;
|
||||
pub use session::{next_correlation_id, Session};
|
||||
#[doc(inline)]
|
||||
pub use value::{MxArrayProjection, MxArrayValue, MxStatus, MxValue, MxValueProjection};
|
||||
#[doc(inline)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user