Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1156960b9 | |||
| 5539ec8542 | |||
| 73e54e252d | |||
| 70d959bd9b | |||
| 0c5b796e2e | |||
| 47dc9d865f | |||
| 4f757e3c0c | |||
| 2f0ee4c961 | |||
| 0859d47f75 | |||
| 7ea8358c06 | |||
| a5944bbe5d | |||
| 04bce3ff9f | |||
| 9572045787 | |||
| 7e1af37eb1 | |||
| 05009d7370 | |||
| f4dc11bae4 | |||
| c3b466e13d | |||
| 792e3f9445 | |||
| ae281d06bb | |||
| 3ca2799c90 | |||
| 459a88b3e7 | |||
| 437ab65fc1 | |||
| 679562e5ed | |||
| dbf550da8b | |||
| 3965a7741e | |||
| abb2cfb84b | |||
| 4e0d8ccfed | |||
| a935aa8b7c | |||
| 9912389fa1 | |||
| f1129b969d | |||
| c51b6f9ce4 | |||
| e39972357b | |||
| 9ad17e2964 | |||
| ef0a883a81 | |||
| 62ba5e9487 | |||
| 136614be94 | |||
| a912bffad5 | |||
| 9bdb899774 | |||
| e5c704de69 | |||
| 4e520f9c0c | |||
| 2eb81379e4 | |||
| ddd5721082 | |||
| 3775f6bf3b | |||
| cdfad420bb | |||
| 330e665f6b | |||
| 5e01ad9c22 | |||
| 77a9108673 | |||
| 192607ab8c | |||
| ba82afe669 | |||
| fe7d1ce1ec | |||
| b8a6695612 | |||
| 6f9188bc8d | |||
| a276f46f81 | |||
| 572b268d81 | |||
| 4c093a64fa | |||
| f47bbaea95 | |||
| c463b49f46 | |||
| 87f86503ef | |||
| e912ef960c | |||
| c4e7ddea70 | |||
| 6bfa4fe884 | |||
| b4a7bac4c0 | |||
| 6df373ae4c | |||
| fe44e3c18a | |||
| 523f944f3e | |||
| c33f1e6047 | |||
| 92cc4688e6 | |||
| a155554038 | |||
| 68f905a344 | |||
| 5abc222c72 | |||
| da3aa7b0b2 | |||
| f0ec068430 | |||
| 1a1d14a9fd | |||
| b2448510ac | |||
| 75610e3f55 | |||
| 5032166106 | |||
| 76a042d663 | |||
| 4a19854eb9 | |||
| a4467e23ef | |||
| eacfeff9fb | |||
| b4bc2df015 | |||
| fd2a0ac4c7 | |||
| 555e4be51f | |||
| 1d8c0d83c4 | |||
| 6600f2a7bd | |||
| 803a207ad2 | |||
| 97e583e96b | |||
| eaf479349d | |||
| 83a4d41fce | |||
| 0d6193cdc4 | |||
| 8cd3e1c20e | |||
| 5c28458624 | |||
| 0b389f5a97 | |||
| 108c4bb118 | |||
| cf54a278e1 | |||
| 81b2aacfe2 | |||
| 5932fe2fd3 | |||
| 310dfab8b4 | |||
| ba157b4b4f | |||
| 87e22dd529 | |||
| d9eaf4b056 | |||
| 2c5c5e5c7e | |||
| b3ebf583ad | |||
| edb812d859 | |||
| 795eee72e3 | |||
| 615b487a77 | |||
| 382861c602 | |||
| ba2b936609 | |||
| 7fc1955287 | |||
| 54480dde61 | |||
| 581b541801 | |||
| d3cb311aae | |||
| 186d03e5cc | |||
| 6bae5ea3a3 |
@@ -45,6 +45,7 @@ build/
|
||||
out/
|
||||
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` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||
- `docs/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,21 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
|
||||
<Authors>Joseph Doherty</Authors>
|
||||
<Company>ZB MOM WW</Company>
|
||||
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||
<Product>MxAccessGateway Client</Product>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
||||
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
|
||||
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
|
||||
<Version>0.1.0</Version>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<!-- Default: do NOT pack. Each project opts in. -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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:
|
||||
|
||||
@@ -196,6 +196,54 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-las
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -239,6 +287,17 @@ Use TLS options for a secured gateway:
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||
```
|
||||
|
||||
### 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:
|
||||
@@ -251,6 +310,29 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
```
|
||||
|
||||
## Installing as a NuGet Package
|
||||
|
||||
The client publishes to the internal Gitea NuGet feed at
|
||||
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
|
||||
|
||||
Add the feed once:
|
||||
|
||||
````bash
|
||||
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
|
||||
--name dohertj2-gitea \
|
||||
--username <gitea-username> \
|
||||
--password <gitea-token-or-password> \
|
||||
--store-password-in-clear-text
|
||||
````
|
||||
|
||||
Then add the package to your project:
|
||||
|
||||
````bash
|
||||
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
|
||||
````
|
||||
|
||||
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -44,6 +44,7 @@ internal sealed class CliArguments
|
||||
|
||||
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
||||
/// <param name="name">The flag name (without '--' prefix).</param>
|
||||
/// <returns>True if the flag was present; otherwise false.</returns>
|
||||
public bool HasFlag(string name)
|
||||
{
|
||||
return _flags.Contains(name);
|
||||
@@ -51,6 +52,7 @@ internal sealed class CliArguments
|
||||
|
||||
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||
/// <returns>The argument value, or null if the argument was not provided.</returns>
|
||||
public string? GetOptional(string name)
|
||||
{
|
||||
return _values.TryGetValue(name, out string? value)
|
||||
@@ -60,6 +62,7 @@ internal sealed class CliArguments
|
||||
|
||||
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||
/// <returns>The argument value.</returns>
|
||||
public string GetRequired(string name)
|
||||
{
|
||||
string? value = GetOptional(name);
|
||||
@@ -74,6 +77,7 @@ internal sealed class CliArguments
|
||||
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
||||
/// <returns>The parsed int32 value, or the default if absent.</returns>
|
||||
public int GetInt32(string name, int? defaultValue = null)
|
||||
{
|
||||
string? value = GetOptional(name);
|
||||
@@ -93,6 +97,7 @@ internal sealed class CliArguments
|
||||
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||
/// <returns>The parsed uint32 value, or the default if absent.</returns>
|
||||
public uint GetUInt32(string name, uint defaultValue)
|
||||
{
|
||||
string? value = GetOptional(name);
|
||||
@@ -104,6 +109,7 @@ internal sealed class CliArguments
|
||||
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||
/// <returns>The parsed uint64 value, or the default if absent.</returns>
|
||||
public ulong GetUInt64(string name, ulong defaultValue)
|
||||
{
|
||||
string? value = GetOptional(name);
|
||||
@@ -115,6 +121,7 @@ internal sealed class CliArguments
|
||||
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||
/// <returns>The parsed TimeSpan value, or the default if absent.</returns>
|
||||
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
||||
{
|
||||
string? value = GetOptional(name);
|
||||
|
||||
@@ -100,7 +100,8 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
||||
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Disposes the galaxy client (if created) and the underlying gateway client.</summary>
|
||||
/// <returns>A value task that completes when both clients are disposed.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_galaxyClient.IsValueCreated)
|
||||
|
||||
@@ -6,6 +6,7 @@ internal static class MxGatewayCliSecretRedactor
|
||||
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
||||
/// <param name="value">The message text to redact.</param>
|
||||
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
||||
/// <returns>The message text with any API key occurrence replaced by <c>[redacted]</c>.</returns>
|
||||
public static string Redact(string value, string? apiKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
||||
|
||||
@@ -22,6 +22,7 @@ public static class MxGatewayClientCli
|
||||
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
||||
/// <param name="standardOutput">TextWriter for command output.</param>
|
||||
/// <param name="standardError">TextWriter for error messages.</param>
|
||||
/// <returns>The process exit code (0 for success, 1 for error).</returns>
|
||||
public static int Run(
|
||||
string[] args,
|
||||
TextWriter standardOutput,
|
||||
@@ -38,6 +39,7 @@ public static class MxGatewayClientCli
|
||||
/// <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>
|
||||
/// <returns>A task that resolves to the process exit code (0 for success, 1 for error).</returns>
|
||||
public static Task<int> RunAsync(
|
||||
string[] args,
|
||||
TextWriter standardOutput,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,10 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
/// </summary>
|
||||
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the gateway client options.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public MxGatewayClientOptions Options { get; } = options;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw gRPC client; always null for the fake.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
||||
|
||||
/// <summary>
|
||||
@@ -48,6 +44,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>
|
||||
@@ -65,11 +62,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
/// </summary>
|
||||
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||
/// </summary>
|
||||
/// <param name="request">The TestConnectionRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<TestConnectionReply> TestConnectionAsync(
|
||||
TestConnectionRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -83,11 +76,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
return Task.FromResult(TestConnectionReply);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||
/// </summary>
|
||||
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||
GetLastDeployTimeRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -101,11 +90,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
return Task.FromResult(GetLastDeployTimeReply);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||
/// </summary>
|
||||
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||
DiscoverHierarchyRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -122,6 +107,35 @@ 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();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||
BrowseChildrenRequest request,
|
||||
CallOptions callOptions)
|
||||
{
|
||||
BrowseChildrenCalls.Add((request, callOptions));
|
||||
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
|
||||
{
|
||||
return Task.FromException<BrowseChildrenReply>(exception);
|
||||
}
|
||||
|
||||
return Task.FromResult(
|
||||
BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
|
||||
? reply
|
||||
: BrowseChildrenReply);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
||||
/// </summary>
|
||||
@@ -143,11 +157,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
/// </summary>
|
||||
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
|
||||
/// </summary>
|
||||
/// <param name="request">The WatchDeployEventsRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||
WatchDeployEventsRequest request,
|
||||
CallOptions callOptions)
|
||||
|
||||
@@ -11,14 +11,10 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
||||
private readonly List<MxEvent> _events = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the gateway client options.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public MxGatewayClientOptions Options { get; } = options;
|
||||
|
||||
/// <summary>
|
||||
/// Gets null, since this is a test fake without a real gRPC client.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
||||
|
||||
/// <summary>
|
||||
@@ -102,11 +98,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
/// </summary>
|
||||
public Queue<Exception> InvokeExceptions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
|
||||
/// </summary>
|
||||
/// <param name="request">The OpenSessionRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<OpenSessionReply> OpenSessionAsync(
|
||||
OpenSessionRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -120,11 +112,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
return Task.FromResult(OpenSessionReply);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
|
||||
/// </summary>
|
||||
/// <param name="request">The CloseSessionRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<CloseSessionReply> CloseSessionAsync(
|
||||
CloseSessionRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -138,11 +126,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
return Task.FromResult(CloseSessionReply);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
|
||||
/// </summary>
|
||||
/// <param name="request">The MxCommandRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public Task<MxCommandReply> InvokeAsync(
|
||||
MxCommandRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -156,11 +140,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
return Task.FromResult(_invokeReplies.Dequeue());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
|
||||
/// </summary>
|
||||
/// <param name="request">The StreamEventsRequest to process.</param>
|
||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||
StreamEventsRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -193,9 +173,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
_events.Add(gatewayEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -216,9 +194,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the query call and yields each enqueued snapshot.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -234,20 +210,20 @@ 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>
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions)
|
||||
@@ -263,6 +239,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
||||
{
|
||||
@@ -27,6 +28,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
||||
{
|
||||
@@ -42,6 +44,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
||||
{
|
||||
@@ -58,6 +61,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
||||
{
|
||||
@@ -79,6 +83,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
||||
{
|
||||
@@ -141,6 +146,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
||||
{
|
||||
@@ -161,6 +167,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
||||
{
|
||||
@@ -181,6 +188,10 @@ 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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||
{
|
||||
@@ -212,6 +223,10 @@ public sealed class GalaxyRepositoryClientTests
|
||||
Assert.True(request.HistorizedOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||
{
|
||||
@@ -229,6 +244,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
||||
{
|
||||
@@ -245,6 +261,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
||||
{
|
||||
@@ -281,6 +298,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
||||
{
|
||||
@@ -319,6 +337,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
||||
{
|
||||
@@ -363,6 +382,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
||||
{
|
||||
@@ -378,6 +398,7 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
||||
{
|
||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||
children: [BuildObject(1, "Plant", isArea: true)],
|
||||
childHasChildren: [true],
|
||||
cacheSequence: 7));
|
||||
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||
children: [BuildObject(2, "Mixer_001")],
|
||||
childHasChildren: [false],
|
||||
cacheSequence: 7));
|
||||
|
||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||
|
||||
// Fire ten concurrent expands of the same node.
|
||||
Task[] tasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => roots[0].ExpandAsync())
|
||||
.ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.True(roots[0].IsExpanded);
|
||||
Assert.Single(roots[0].Children);
|
||||
// 1 roots fetch + exactly 1 expand fetch = 2 total
|
||||
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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,8 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClientAlarmsTests
|
||||
{
|
||||
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
||||
{
|
||||
@@ -46,6 +48,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||
{
|
||||
@@ -69,6 +73,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
cancellation.Token));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||
{
|
||||
@@ -93,6 +99,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
||||
{
|
||||
@@ -117,6 +125,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Single(transport.QueryActiveAlarmsCalls);
|
||||
}
|
||||
|
||||
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
||||
{
|
||||
@@ -136,6 +146,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
||||
}
|
||||
|
||||
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
||||
{
|
||||
@@ -38,6 +39,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
||||
{
|
||||
@@ -83,6 +85,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||
{
|
||||
@@ -107,6 +110,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||
{
|
||||
@@ -149,6 +153,7 @@ public sealed class MxGatewayClientCliTests
|
||||
|
||||
|
||||
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||
{
|
||||
@@ -188,6 +193,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||
{
|
||||
@@ -230,6 +236,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||
{
|
||||
@@ -261,6 +268,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
||||
{
|
||||
@@ -291,6 +299,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
||||
{
|
||||
@@ -361,6 +370,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
||||
{
|
||||
@@ -415,6 +425,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
||||
{
|
||||
@@ -450,6 +461,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
||||
{
|
||||
@@ -476,6 +488,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
||||
{
|
||||
@@ -519,6 +532,8 @@ public sealed class MxGatewayClientCliTests
|
||||
/// 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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("stream-alarms")]
|
||||
[InlineData("acknowledge-alarm")]
|
||||
@@ -573,6 +588,7 @@ public sealed class MxGatewayClientCliTests
|
||||
/// against a zero server handle. The fix must fail loudly with a
|
||||
/// descriptive <see cref="MxGatewayException"/>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
||||
{
|
||||
@@ -623,6 +639,7 @@ public sealed class MxGatewayClientCliTests
|
||||
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
||||
/// the bench must exit promptly when the supplied token cancels.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
||||
{
|
||||
@@ -716,6 +733,8 @@ public sealed class MxGatewayClientCliTests
|
||||
/// 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>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("read-bulk")]
|
||||
[InlineData("bench-read-bulk")]
|
||||
@@ -878,7 +897,8 @@ public sealed class MxGatewayClientCliTests
|
||||
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
||||
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Releases resources held by the fake CLI client.</summary>
|
||||
/// <returns>A completed value task.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
@@ -988,6 +1008,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>
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
public sealed class MxGatewayClientSessionTests
|
||||
{
|
||||
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
||||
{
|
||||
@@ -37,6 +39,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
||||
{
|
||||
@@ -62,6 +65,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
||||
{
|
||||
@@ -87,6 +91,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
||||
{
|
||||
@@ -118,6 +123,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
||||
{
|
||||
@@ -146,6 +152,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
||||
{
|
||||
@@ -185,6 +192,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||
{
|
||||
@@ -216,6 +224,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||
{
|
||||
@@ -232,6 +241,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
||||
{
|
||||
@@ -256,6 +266,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
||||
{
|
||||
@@ -269,6 +280,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
||||
{
|
||||
@@ -284,6 +296,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
public sealed class MxGatewayGeneratedContractTests
|
||||
{
|
||||
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||
{
|
||||
|
||||
@@ -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,92 @@ 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);
|
||||
}
|
||||
|
||||
/// <summary>Builds a <see cref="BrowseChildrenRequest"/> from the provided options.</summary>
|
||||
/// <param name="options">Browse children options to convert.</param>
|
||||
/// <returns>The constructed request message.</returns>
|
||||
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
|
||||
@@ -336,6 +427,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Closes the gRPC channel and releases resources.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -402,7 +494,13 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||
CreateHttpHandlerForTests(options);
|
||||
|
||||
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
||||
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
||||
/// <returns>The configured HTTP message handler.</returns>
|
||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||
{
|
||||
SocketsHttpHandler handler = new()
|
||||
{
|
||||
@@ -422,6 +520,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 +540,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
return customChain.Build(certificateToValidate);
|
||||
};
|
||||
}
|
||||
else if (!options.RequireCertificateValidation)
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
|
||||
@@ -10,9 +10,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
||||
MxGatewayClientOptions options,
|
||||
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the gateway client options.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public MxGatewayClientOptions Options { get; } = options;
|
||||
|
||||
/// <summary>
|
||||
@@ -75,6 +73,27 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Streams deploy events from the Galaxy Repository, using an explicit cancellation token that overrides the call options token when provided.</summary>
|
||||
/// <param name="request">The watch deploy events request.</param>
|
||||
/// <param name="callOptions">Call options for the underlying gRPC call.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token; takes precedence over the token in <paramref name="callOptions"/> when cancellable.</param>
|
||||
/// <returns>An async enumerable of deploy events.</returns>
|
||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||
WatchDeployEventsRequest request,
|
||||
CallOptions callOptions,
|
||||
|
||||
@@ -10,9 +10,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
MxGatewayClientOptions options,
|
||||
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the gateway client options.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public MxGatewayClientOptions Options { get; } = options;
|
||||
|
||||
/// <summary>
|
||||
@@ -74,7 +72,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Streams MXAccess events from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||
/// <param name="request">The stream events request.</param>
|
||||
/// <param name="callOptions">gRPC call options.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||
/// <returns>An async enumerable of MXAccess events.</returns>
|
||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||
StreamEventsRequest request,
|
||||
CallOptions callOptions,
|
||||
@@ -133,7 +135,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Queries active alarms from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||
/// <param name="request">The query active alarms request.</param>
|
||||
/// <param name="callOptions">gRPC call options.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||
/// <returns>An async enumerable of active alarm snapshots.</returns>
|
||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||
QueryActiveAlarmsRequest request,
|
||||
CallOptions callOptions,
|
||||
@@ -175,7 +181,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
||||
return QueryActiveAlarmsAsync(request, callOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Streams alarm feed messages from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||
/// <param name="request">The stream alarms request.</param>
|
||||
/// <param name="callOptions">gRPC call options.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||
StreamAlarmsRequest request,
|
||||
CallOptions callOptions,
|
||||
|
||||
@@ -15,6 +15,7 @@ internal interface IGalaxyRepositoryClientTransport
|
||||
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
||||
/// <param name="request">The test connection request.</param>
|
||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||
/// <returns>A task that resolves to the test connection reply.</returns>
|
||||
Task<TestConnectionReply> TestConnectionAsync(
|
||||
TestConnectionRequest request,
|
||||
CallOptions callOptions);
|
||||
@@ -22,6 +23,7 @@ internal interface IGalaxyRepositoryClientTransport
|
||||
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
||||
/// <param name="request">The get last deploy time request.</param>
|
||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||
/// <returns>A task that resolves to the last deploy time reply.</returns>
|
||||
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||
GetLastDeployTimeRequest request,
|
||||
CallOptions callOptions);
|
||||
@@ -29,13 +31,23 @@ internal interface IGalaxyRepositoryClientTransport
|
||||
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
||||
/// <param name="request">The discover hierarchy request.</param>
|
||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||
/// <returns>A task that resolves to the hierarchy discovery reply.</returns>
|
||||
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||
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>
|
||||
/// <returns>A task that resolves to the browse children reply.</returns>
|
||||
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>
|
||||
/// <returns>An async enumerable of deploy events.</returns>
|
||||
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||
WatchDeployEventsRequest request,
|
||||
CallOptions callOptions);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
|
||||
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
|
||||
/// its direct children on demand. Expansion is one-shot: a second call is a
|
||||
/// no-op. Pagination of large sibling sets is handled internally.
|
||||
/// </summary>
|
||||
public sealed class LazyBrowseNode
|
||||
{
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
private readonly BrowseChildrenOptions _options;
|
||||
private readonly List<LazyBrowseNode> _children = [];
|
||||
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
||||
private bool _isExpanded;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="LazyBrowseNode"/>.</summary>
|
||||
/// <param name="client">The repository client used to fetch children.</param>
|
||||
/// <param name="object">The underlying Galaxy object for this node.</param>
|
||||
/// <param name="hasChildrenHint">True when the server reports the node has at least one matching descendant.</param>
|
||||
/// <param name="options">Options controlling child browse behavior.</param>
|
||||
internal LazyBrowseNode(
|
||||
GalaxyRepositoryClient client,
|
||||
GalaxyObject @object,
|
||||
bool hasChildrenHint,
|
||||
BrowseChildrenOptions options)
|
||||
{
|
||||
_client = client;
|
||||
Object = @object;
|
||||
HasChildrenHint = hasChildrenHint;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>The underlying Galaxy object for this node.</summary>
|
||||
public GalaxyObject Object { get; }
|
||||
|
||||
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
|
||||
public bool HasChildrenHint { get; }
|
||||
|
||||
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
|
||||
public IReadOnlyList<LazyBrowseNode> Children => _children;
|
||||
|
||||
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
|
||||
public bool IsExpanded => _isExpanded;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
|
||||
/// Idempotent: subsequent calls are no-ops.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
|
||||
/// (after the first completes) return immediately.
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_isExpanded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_isExpanded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string pageToken = string.Empty;
|
||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||
do
|
||||
{
|
||||
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
|
||||
request.ParentGobjectId = Object.GobjectId;
|
||||
request.PageToken = pageToken;
|
||||
|
||||
BrowseChildrenReply reply = await _client
|
||||
.BrowseChildrenRawAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
for (int i = 0; i < reply.Children.Count; i++)
|
||||
{
|
||||
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
||||
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
|
||||
}
|
||||
|
||||
pageToken = reply.NextPageToken;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
||||
{
|
||||
throw new MxGatewayException(
|
||||
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
||||
}
|
||||
}
|
||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||
|
||||
_isExpanded = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_expandLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public static class MxCommandReplyExtensions
|
||||
{
|
||||
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
||||
/// <param name="reply">The command reply to check.</param>
|
||||
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
||||
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reply);
|
||||
@@ -24,6 +25,7 @@ public static class MxCommandReplyExtensions
|
||||
|
||||
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
||||
/// <param name="reply">The command reply to check.</param>
|
||||
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
||||
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reply);
|
||||
|
||||
@@ -249,6 +249,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Disposes the client and releases all resources.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -315,7 +316,13 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||
CreateHttpHandlerForTests(options);
|
||||
|
||||
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
||||
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
||||
/// <returns>The configured HTTP message handler.</returns>
|
||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||
{
|
||||
SocketsHttpHandler handler = new()
|
||||
{
|
||||
@@ -335,6 +342,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;
|
||||
@@ -350,6 +362,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>
|
||||
|
||||
@@ -12,6 +12,7 @@ internal static class MxGatewayClientRetryPolicy
|
||||
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
||||
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
||||
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
||||
/// <returns>A configured <see cref="ResiliencePipeline"/> with exponential-backoff retry.</returns>
|
||||
public static ResiliencePipeline Create(
|
||||
MxGatewayClientRetryOptions options,
|
||||
ILogger? logger)
|
||||
@@ -42,6 +43,7 @@ internal static class MxGatewayClientRetryPolicy
|
||||
|
||||
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
||||
/// <param name="kind">The command kind to check.</param>
|
||||
/// <returns><see langword="true"/> if the command kind is safe to retry; otherwise <see langword="false"/>.</returns>
|
||||
public static bool IsRetryableCommand(MxCommandKind kind)
|
||||
{
|
||||
return kind is MxCommandKind.Ping
|
||||
|
||||
@@ -211,6 +211,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task AdviseAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -252,6 +253,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task UnAdviseAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -293,6 +295,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task RemoveItemAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -675,6 +678,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="userId">User ID context for the write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -729,6 +733,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||
/// <param name="userId">User ID context for the write.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task Write2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -821,6 +826,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <summary>
|
||||
/// Closes the session and releases resources.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -7,6 +7,7 @@ public static class MxStatusProxyExtensions
|
||||
{
|
||||
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
||||
/// <param name="status">The status to check.</param>
|
||||
/// <returns><c>true</c> if the status is successful; <c>false</c> otherwise.</returns>
|
||||
public static bool IsSuccess(this MxStatusProxy status)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
@@ -17,6 +18,7 @@ public static class MxStatusProxyExtensions
|
||||
|
||||
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
||||
/// <param name="status">The status to summarize.</param>
|
||||
/// <returns>A human-readable string combining category, source, detail, and diagnostic text.</returns>
|
||||
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class MxValueExtensions
|
||||
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
||||
/// </summary>
|
||||
/// <param name="value">Scalar boolean value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c>.</returns>
|
||||
public static MxValue ToMxValue(this bool value)
|
||||
{
|
||||
return new MxValue
|
||||
@@ -28,6 +29,7 @@ public static class MxValueExtensions
|
||||
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
||||
/// </summary>
|
||||
/// <param name="value">32-bit integer value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
||||
public static MxValue ToMxValue(this int value)
|
||||
{
|
||||
return new MxValue
|
||||
@@ -42,6 +44,7 @@ public static class MxValueExtensions
|
||||
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
||||
/// </summary>
|
||||
/// <param name="value">64-bit integer value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
||||
public static MxValue ToMxValue(this long value)
|
||||
{
|
||||
return new MxValue
|
||||
@@ -56,6 +59,7 @@ public static class MxValueExtensions
|
||||
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
||||
/// </summary>
|
||||
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c>.</returns>
|
||||
public static MxValue ToMxValue(this float value)
|
||||
{
|
||||
return new MxValue
|
||||
@@ -70,6 +74,7 @@ public static class MxValueExtensions
|
||||
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
||||
/// </summary>
|
||||
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c>.</returns>
|
||||
public static MxValue ToMxValue(this double value)
|
||||
{
|
||||
return new MxValue
|
||||
@@ -84,6 +89,7 @@ public static class MxValueExtensions
|
||||
/// Converts a string value to an MxValue with MxDataType.String.
|
||||
/// </summary>
|
||||
/// <param name="value">String value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c>.</returns>
|
||||
public static MxValue ToMxValue(this string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
@@ -100,6 +106,7 @@ public static class MxValueExtensions
|
||||
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
||||
/// </summary>
|
||||
/// <param name="value">DateTimeOffset value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
||||
public static MxValue ToMxValue(this DateTimeOffset value)
|
||||
{
|
||||
return new MxValue
|
||||
@@ -114,6 +121,7 @@ public static class MxValueExtensions
|
||||
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
||||
/// </summary>
|
||||
/// <param name="value">DateTime value to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
||||
public static MxValue ToMxValue(this DateTime value)
|
||||
{
|
||||
return new DateTimeOffset(
|
||||
@@ -127,6 +135,7 @@ public static class MxValueExtensions
|
||||
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
||||
/// </summary>
|
||||
/// <param name="values">Array of boolean values to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c> and an array payload.</returns>
|
||||
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -145,6 +154,7 @@ public static class MxValueExtensions
|
||||
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
||||
/// </summary>
|
||||
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
||||
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -163,6 +173,7 @@ public static class MxValueExtensions
|
||||
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
||||
/// </summary>
|
||||
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
||||
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -181,6 +192,7 @@ public static class MxValueExtensions
|
||||
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
||||
/// </summary>
|
||||
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c> and an array payload.</returns>
|
||||
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -199,6 +211,7 @@ public static class MxValueExtensions
|
||||
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
||||
/// </summary>
|
||||
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c> and an array payload.</returns>
|
||||
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -217,6 +230,7 @@ public static class MxValueExtensions
|
||||
/// Converts a string array to an MxValue with MxDataType.String.
|
||||
/// </summary>
|
||||
/// <param name="values">Array of string values to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c> and an array payload.</returns>
|
||||
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -235,6 +249,7 @@ public static class MxValueExtensions
|
||||
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
||||
/// </summary>
|
||||
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c> and an array payload.</returns>
|
||||
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
@@ -253,6 +268,7 @@ public static class MxValueExtensions
|
||||
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
||||
/// </summary>
|
||||
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
||||
/// <returns>The JSON field name of the active oneof case, or <c>"nullValue"</c>/<c>"unspecified"</c> for null/unset values.</returns>
|
||||
public static string GetProjectionKind(this MxValue value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
@@ -276,6 +292,7 @@ public static class MxValueExtensions
|
||||
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
||||
/// </summary>
|
||||
/// <param name="value">The MxValue to convert.</param>
|
||||
/// <returns>The boxed CLR value, or null if the MxValue represents a null.</returns>
|
||||
public static object? ToClrValue(this MxValue value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
@@ -299,6 +316,7 @@ public static class MxValueExtensions
|
||||
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
||||
/// </summary>
|
||||
/// <param name="array">The MxArray to convert.</param>
|
||||
/// <returns>A CLR array of the appropriate element type, or null for unknown element types.</returns>
|
||||
public static object? ToClrArrayValue(this MxArray array)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(array);
|
||||
@@ -328,6 +346,7 @@ public static class MxValueExtensions
|
||||
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
||||
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
||||
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
||||
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Unknown</c> and the raw byte payload.</returns>
|
||||
public static MxValue ToRawMxValue(
|
||||
byte[] value,
|
||||
string variantType,
|
||||
|
||||
@@ -16,4 +16,21 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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
|
||||
@@ -121,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
|
||||
@@ -213,6 +283,38 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
|
||||
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
||||
```
|
||||
|
||||
## Installing the Go client
|
||||
|
||||
The module is resolved directly from the git repo — no package registry:
|
||||
|
||||
````bash
|
||||
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
|
||||
````
|
||||
|
||||
Then import:
|
||||
|
||||
````go
|
||||
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
````
|
||||
|
||||
If your build environment cannot reach `gitea.dohertylan.com` directly,
|
||||
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
|
||||
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
|
||||
for the internal module path.
|
||||
|
||||
## Releasing a new version
|
||||
|
||||
Go modules in monorepo subdirectories use prefixed tags. To tag a release
|
||||
from this repo:
|
||||
|
||||
````bash
|
||||
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
|
||||
````
|
||||
|
||||
The script validates semver, refuses to tag with uncommitted tracked
|
||||
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
|
||||
pushes it to origin.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+96
-2
@@ -57,6 +57,16 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
|
||||
}
|
||||
```
|
||||
|
||||
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
|
||||
no `caCertificatePath` accepts whatever certificate the gateway presents (via
|
||||
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
|
||||
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
|
||||
verify against the JVM trust store without pinning. Use `serverNameOverride` /
|
||||
`--server-name-override` when the dialed host differs from the certificate SAN.
|
||||
See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
||||
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||
underlying protobuf messages. `MxGatewayCommandException` and
|
||||
@@ -116,6 +126,59 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localh
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||
```
|
||||
|
||||
### Browsing lazily
|
||||
|
||||
For UI trees or OPC UA bridges, use `browseChildren` to walk one level at a
|
||||
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
|
||||
default request for root objects; subsequent calls set `parentGobjectId`,
|
||||
`parentTagName`, or `parentContainedPath`. Filter fields match
|
||||
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
|
||||
`getChildHasChildrenList()` so you know which nodes to expand. See
|
||||
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||
request and filter semantics. This snippet documents the API as it appears once
|
||||
the Java client is regenerated on the Windows host.
|
||||
|
||||
```java
|
||||
BrowseChildrenReply reply = galaxy.browseChildren(
|
||||
BrowseChildrenRequest.newBuilder().build());
|
||||
|
||||
List<GalaxyObject> children = reply.getChildrenList();
|
||||
List<Boolean> hasChildren = reply.getChildHasChildrenList();
|
||||
for (int i = 0; i < children.size(); i++) {
|
||||
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
|
||||
}
|
||||
```
|
||||
|
||||
#### High-level walker
|
||||
|
||||
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||
sibling pagination and the `child_has_children` hint for you:
|
||||
|
||||
```java
|
||||
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||
.endpoint("localhost:5000")
|
||||
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
|
||||
.plaintext(true)
|
||||
.build();
|
||||
|
||||
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
|
||||
List<LazyBrowseNode> roots = galaxy.browse();
|
||||
for (LazyBrowseNode root : roots) {
|
||||
if (root.hasChildrenHint()) {
|
||||
root.expand();
|
||||
}
|
||||
for (LazyBrowseNode child : root.getChildren()) {
|
||||
String kind = child.hasChildrenHint() ? "has children" : "leaf";
|
||||
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`expand` is idempotent — calling it twice fires only one RPC,
|
||||
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||
`browse` again from the root.
|
||||
|
||||
### Watching deploy events
|
||||
|
||||
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
||||
@@ -179,8 +242,8 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 -
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="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 --session-id <id> --limit 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --alarm-reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="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"
|
||||
```
|
||||
|
||||
@@ -229,6 +292,37 @@ $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||
```
|
||||
|
||||
## Installing from the Gitea Maven repository
|
||||
|
||||
The client publishes to the internal Gitea Maven repository at
|
||||
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
|
||||
|
||||
In your consumer project's `build.gradle`:
|
||||
|
||||
````groovy
|
||||
repositories {
|
||||
maven {
|
||||
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||
credentials {
|
||||
username = System.getenv('GITEA_USERNAME')
|
||||
password = System.getenv('GITEA_TOKEN')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
|
||||
}
|
||||
````
|
||||
|
||||
To publish a new version from this repo:
|
||||
|
||||
````bash
|
||||
export GITEA_USERNAME=dohertj2
|
||||
export GITEA_TOKEN=<your-gitea-token>
|
||||
gradle :zb-mom-ww-mxgateway-client:publish
|
||||
````
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -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
+138
-4
@@ -33,6 +33,7 @@ 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;
|
||||
@@ -119,7 +120,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));
|
||||
@@ -154,6 +155,120 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
||||
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
|
||||
@@ -183,8 +298,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
if (line.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
String[] args = line.trim().split("\\s+");
|
||||
if (args.length == 0 || (args.length == 1 && args[0].isEmpty())) {
|
||||
String[] args = tokenizeBatchLine(line);
|
||||
if (args.length == 0) {
|
||||
continue;
|
||||
}
|
||||
StringWriter cmdOut = new StringWriter();
|
||||
@@ -1079,11 +1194,29 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
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) {
|
||||
queue.offer(value);
|
||||
if (!queue.offer(value)) {
|
||||
MxGatewayAlarmFeedSubscription sub = subscriptionRef.get();
|
||||
if (sub != null) {
|
||||
sub.cancel();
|
||||
}
|
||||
queue.clear();
|
||||
queue.offer(new IllegalStateException(
|
||||
"stream-alarms queue overflowed (capacity 1024); consumer too slow"));
|
||||
queue.offer(ALARM_FEED_END);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1096,6 +1229,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
queue.offer(ALARM_FEED_END);
|
||||
}
|
||||
});
|
||||
subscriptionRef.set(subscription);
|
||||
try {
|
||||
int count = 0;
|
||||
while (true) {
|
||||
|
||||
+216
@@ -225,6 +225,89 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.errors().contains("--reference"), run.errors());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readmeDocumentedStreamAlarmsExampleParsesCleanly() {
|
||||
// Client.Java-032 regression — the README's stream-alarms example
|
||||
// (clients/java/README.md:182) must round-trip through picocli's
|
||||
// parser without a parse error. Before the fix, the example used
|
||||
// a non-existent --session-id option and picocli failed at parse
|
||||
// time. This test pins the exact tokens documented in the README.
|
||||
String[] args = {
|
||||
"stream-alarms",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--api-key-env",
|
||||
"MXGATEWAY_API_KEY",
|
||||
"--plaintext",
|
||||
"--filter-prefix",
|
||||
"Galaxy",
|
||||
"--limit",
|
||||
"1",
|
||||
"--json"
|
||||
};
|
||||
assertReadmeExampleParses(args);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readmeDocumentedAcknowledgeAlarmExampleParsesCleanly() {
|
||||
// Client.Java-032 regression — the README's acknowledge-alarm
|
||||
// example (clients/java/README.md:183) must parse without error.
|
||||
// Before the fix it used --session-id (no such option) and
|
||||
// --alarm-reference (the real option is --reference), so picocli
|
||||
// rejected the invocation immediately.
|
||||
String[] args = {
|
||||
"acknowledge-alarm",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--api-key-env",
|
||||
"MXGATEWAY_API_KEY",
|
||||
"--plaintext",
|
||||
"--reference",
|
||||
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||
"--json"
|
||||
};
|
||||
assertReadmeExampleParses(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the given args through the production picocli {@link CommandLine}
|
||||
* and asserts no parser error, no unknown option, and no missing required
|
||||
* option. Does not execute the command body — only the option / subcommand
|
||||
* parser is exercised, so no network call is made.
|
||||
*/
|
||||
private static void assertReadmeExampleParses(String[] args) {
|
||||
picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
|
||||
try {
|
||||
commandLine.parseArgs(args);
|
||||
} catch (picocli.CommandLine.ParameterException ex) {
|
||||
throw new AssertionError(
|
||||
"documented README invocation failed picocli parse: "
|
||||
+ String.join(" ", args)
|
||||
+ " -> "
|
||||
+ ex.getMessage(),
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamAlarmsCommandFailsFastOnQueueOverflow() {
|
||||
// Client.Java-033 regression — the CLI's stream-alarms bounded queue
|
||||
// used queue.offer(value) which silently dropped messages past
|
||||
// capacity (1024). After the fix the CLI must surface the overflow
|
||||
// as a non-zero exit (mirroring MxEventStream's fail-fast contract).
|
||||
//
|
||||
// The OverflowingFakeClient floods the gRPC observer with 2000
|
||||
// messages synchronously, which exceeds the bounded 1024-element
|
||||
// queue. The fix detects the failed offer, cancels the subscription,
|
||||
// queues an overflow exception, and the drain loop surfaces it.
|
||||
OverflowingFakeClientFactory factory = new OverflowingFakeClientFactory();
|
||||
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Flood");
|
||||
|
||||
assertFalse(run.exitCode() == 0,
|
||||
"expected non-zero exit when the alarm queue overflows; got exit=" + run.exitCode()
|
||||
+ " out=\n" + run.output() + "\nerr=\n" + run.errors());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandExecutesVersionAndEmitsEorMarker() {
|
||||
CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
|
||||
@@ -235,6 +318,68 @@ final class MxGatewayCliTests {
|
||||
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).
|
||||
@@ -290,6 +435,77 @@ final class MxGatewayCliTests {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory whose fake client floods the {@code streamAlarms} observer with
|
||||
* 2000 messages synchronously, exceeding the CLI's bounded 1024-element
|
||||
* queue. Used by the Client.Java-033 fail-fast overflow regression.
|
||||
*/
|
||||
private static final class OverflowingFakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
||||
@Override
|
||||
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
||||
return new OverflowingFakeClient(options.spec.commandLine().getOut());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class OverflowingFakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||
private final PrintWriter out;
|
||||
|
||||
OverflowingFakeClient(PrintWriter out) {
|
||||
this.out = out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintWriter out() {
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpenSessionReply openSession(OpenSessionRequest request) {
|
||||
return OpenSessionReply.newBuilder().setSessionId("flood-session").setProtocolStatus(ok()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CloseSessionReply closeSession(CloseSessionRequest request) {
|
||||
return CloseSessionReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
||||
.setProtocolStatus(ok())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||
// Synchronously push 2000 messages to overflow the CLI's bounded
|
||||
// 1024-element queue. The CLI must surface the overflow rather
|
||||
// than silently dropping the trailing ~976 messages.
|
||||
for (int i = 0; i < 2000; i++) {
|
||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||
.setAlarmFullReference("Flood." + i)
|
||||
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||
.setSeverity(700))
|
||||
.build());
|
||||
}
|
||||
observer.onCompleted();
|
||||
return new MxGatewayAlarmFeedSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||
private final PrintWriter out;
|
||||
private final FakeSession session = new FakeSession();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+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.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
|
||||
@@ -15,53 +11,13 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||
* 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 implements AutoCloseable {
|
||||
private final AtomicReference<ClientCallStreamObserver<StreamAlarmsRequest>> requestStream = new AtomicReference<>();
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
|
||||
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> wrap(StreamObserver<AlarmFeedMessage> observer) {
|
||||
return new ClientResponseObserver<>() {
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<StreamAlarmsRequest> stream) {
|
||||
requestStream.set(stream);
|
||||
if (cancelled.get()) {
|
||||
stream.cancel("client cancelled alarm feed", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(AlarmFeedMessage value) {
|
||||
observer.onNext(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
observer.onError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
||||
* started; cancellation is recorded and applied as soon as the stream
|
||||
* attaches.
|
||||
*/
|
||||
public void cancel() {
|
||||
cancelled.set(true);
|
||||
ClientCallStreamObserver<StreamAlarmsRequest> stream = requestStream.get();
|
||||
if (stream != null) {
|
||||
stream.cancel("client cancelled alarm feed", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
cancel();
|
||||
public final class MxGatewayAlarmFeedSubscription
|
||||
extends MxGatewayStreamSubscription<StreamAlarmsRequest, AlarmFeedMessage> {
|
||||
public MxGatewayAlarmFeedSubscription() {
|
||||
super("client cancelled alarm feed");
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -384,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();
|
||||
}
|
||||
@@ -393,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");
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -138,6 +138,49 @@ The methods return native Python types (`bool`, `datetime | None`, and a
|
||||
into the hierarchy without learning the underlying stub class. The
|
||||
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
|
||||
@@ -187,6 +230,17 @@ The client supports plaintext channels for local development, TLS with system
|
||||
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
||||
API keys are redacted from option repr output and CLI error output.
|
||||
|
||||
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||
grpc-python has no per-channel skip-verify, so the lenient TLS default is
|
||||
**trust-on-first-use**: with no `ca_file` and `require_certificate_validation`
|
||||
left `False`, the client fetches the gateway's presented certificate once
|
||||
(unverified) and pins it for the channel, defaulting the SNI/target-name override
|
||||
to `localhost` (the generated certificate always carries a `localhost` SAN) when
|
||||
none was supplied. To verify instead, pass `ca_file` to verify against a specific
|
||||
CA, or set `require_certificate_validation=True` to verify against the system
|
||||
trust roots. See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI emits deterministic JSON for automation:
|
||||
@@ -225,6 +279,19 @@ $env:MXGATEWAY_TEST_ITEM = 'Object.Attribute'
|
||||
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
```
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -13,12 +13,35 @@ dependencies = [
|
||||
"grpcio>=1.80,<2",
|
||||
"protobuf>=6.33,<7",
|
||||
]
|
||||
authors = [
|
||||
{ name = "Joseph Doherty" },
|
||||
]
|
||||
license = { text = "Proprietary" }
|
||||
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: Other/Proprietary License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: System :: Distributed Computing",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"grpcio-tools>=1.80,<2",
|
||||
"pytest>=9,<10",
|
||||
"pytest-asyncio>=1.3,<2",
|
||||
"build>=1.2,<2",
|
||||
"twine>=5,<6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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:
|
||||
@@ -139,6 +140,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 +286,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,15 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -18,6 +21,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 +48,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 +56,51 @@ class ClientOptions:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowseChildrenOptions:
|
||||
"""Filters and shape options for ``GalaxyRepositoryClient.browse``.
|
||||
|
||||
Mirrors the AND-combined filter set on ``BrowseChildrenRequest`` so a
|
||||
single instance can be re-used across an entire lazy browse session
|
||||
(the filter set is part of the page-token contract).
|
||||
"""
|
||||
|
||||
category_ids: Sequence[int] = field(default_factory=tuple)
|
||||
template_chain_contains: Sequence[str] = field(default_factory=tuple)
|
||||
tag_name_glob: str | None = None
|
||||
include_attributes: bool | None = None
|
||||
alarm_bearing_only: bool = False
|
||||
historized_only: bool = False
|
||||
|
||||
|
||||
def _split_authority(endpoint: str) -> tuple[str, int]:
|
||||
"""Split a gRPC target (optionally scheme-prefixed) into (host, port).
|
||||
|
||||
Handles bracketed IPv6 literals (e.g. ``[::1]:5120`` or bare ``[::1]``),
|
||||
returning the host without brackets so it is safe to pass to
|
||||
``ssl.get_server_certificate``.
|
||||
"""
|
||||
target = endpoint.split("://", 1)[-1]
|
||||
if target.startswith("["):
|
||||
# Bracketed IPv6: "[::1]:5120" or "[::1]"
|
||||
bracket_end = target.find("]")
|
||||
host = target[1:bracket_end] # strip surrounding brackets
|
||||
remainder = target[bracket_end + 1 :] # ":5120" or ""
|
||||
port_str = remainder.lstrip(":")
|
||||
return (host, int(port_str) if port_str else 443)
|
||||
host, _, port = target.rpartition(":")
|
||||
return (host or "localhost", int(port) if port else 443)
|
||||
|
||||
|
||||
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||
"""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 +112,28 @@ 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).
|
||||
host, port = _split_authority(options.endpoint)
|
||||
try:
|
||||
presented = ssl.get_server_certificate((host, port))
|
||||
except OSError as error:
|
||||
raise MxGatewayTransportError(
|
||||
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
|
||||
) from error
|
||||
credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
|
||||
# The gateway self-signed cert always carries a "localhost" SAN, so default
|
||||
# the SNI/target-name override to it when none was supplied, tolerating
|
||||
# dial-by-IP or hostname mismatch.
|
||||
if not options.server_name_override:
|
||||
channel_options.append(("grpc.ssl_target_name_override", "localhost"))
|
||||
|
||||
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
||||
return grpc.aio.secure_channel(
|
||||
options.endpoint,
|
||||
credentials,
|
||||
|
||||
@@ -72,27 +72,83 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
|
||||
]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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]) -> 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: _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 +158,121 @@ 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),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,165 @@
|
||||
"""TLS behaviour tests for ``create_channel``.
|
||||
|
||||
These spin up a real loopback ``grpc.aio`` server with a freshly generated
|
||||
self-signed certificate (carrying a ``localhost`` SAN, mirroring the gateway's
|
||||
auto-generated cert) and assert the lenient TOFU default lets a client connect
|
||||
without any CA configured.
|
||||
|
||||
Marked ``tls`` and skipped unless ``MXGATEWAY_RUN_TLS_TESTS=1`` because loopback
|
||||
TLS handshakes can be timing-flaky on shared CI runners. This mirrors how the
|
||||
suite gates anything that depends on real sockets rather than fakes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||
from zb_mom_ww_mxgateway.options import create_channel
|
||||
|
||||
pytestmark = pytest.mark.tls
|
||||
|
||||
_RUN_TLS_TESTS = os.environ.get("MXGATEWAY_RUN_TLS_TESTS") == "1"
|
||||
_OPENSSL = shutil.which("openssl")
|
||||
|
||||
requires_tls = pytest.mark.skipif(
|
||||
not _RUN_TLS_TESTS,
|
||||
reason="set MXGATEWAY_RUN_TLS_TESTS=1 to run loopback TLS tests",
|
||||
)
|
||||
requires_openssl = pytest.mark.skipif(
|
||||
_OPENSSL is None,
|
||||
reason="openssl CLI is required to generate a self-signed test certificate",
|
||||
)
|
||||
|
||||
|
||||
def _generate_self_signed_cert(directory: Path) -> tuple[Path, Path]:
|
||||
"""Generate a self-signed cert/key pair with a ``localhost`` SAN."""
|
||||
key_path = directory / "server.key"
|
||||
cert_path = directory / "server.crt"
|
||||
subprocess.run(
|
||||
[
|
||||
str(_OPENSSL),
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
str(key_path),
|
||||
"-out",
|
||||
str(cert_path),
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=mxgateway-test",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost,IP:127.0.0.1",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
class _StaticGatewayServicer(pb_grpc.MxAccessGatewayServicer):
|
||||
"""Minimal servicer answering ``OpenSession`` with a fixed session id."""
|
||||
|
||||
async def OpenSession( # noqa: N802 - generated gRPC method name
|
||||
self, request: pb.OpenSessionRequest, context: object
|
||||
) -> pb.OpenSessionReply:
|
||||
return pb.OpenSessionReply(session_id="tls-session-1")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def tls_server() -> AsyncIterator[int]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cert_path, key_path = _generate_self_signed_cert(Path(tmp))
|
||||
credentials = grpc.ssl_server_credentials(
|
||||
[(key_path.read_bytes(), cert_path.read_bytes())]
|
||||
)
|
||||
server = grpc.aio.server()
|
||||
pb_grpc.add_MxAccessGatewayServicer_to_server(_StaticGatewayServicer(), server)
|
||||
port = _free_port()
|
||||
server.add_secure_port(f"127.0.0.1:{port}", credentials)
|
||||
await server.start()
|
||||
try:
|
||||
yield port
|
||||
finally:
|
||||
await server.stop(grace=None)
|
||||
|
||||
|
||||
@requires_tls
|
||||
@requires_openssl
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_tls_connects_via_tofu(tls_server: int) -> None:
|
||||
"""Default TLS options (no CA) connect by pinning the presented cert."""
|
||||
options = ClientOptions(
|
||||
endpoint=f"127.0.0.1:{tls_server}",
|
||||
api_key="mxgw_test_secret",
|
||||
)
|
||||
channel = create_channel(options)
|
||||
try:
|
||||
stub = pb_grpc.MxAccessGatewayStub(channel)
|
||||
reply = await stub.OpenSession(pb.OpenSessionRequest(), timeout=10)
|
||||
assert reply.session_id == "tls-session-1"
|
||||
finally:
|
||||
await channel.close()
|
||||
|
||||
|
||||
def test_split_authority_parses_host_and_port() -> None:
|
||||
from zb_mom_ww_mxgateway.options import _split_authority
|
||||
|
||||
assert _split_authority("https://10.0.0.5:5120") == ("10.0.0.5", 5120)
|
||||
assert _split_authority("localhost:5120") == ("localhost", 5120)
|
||||
assert _split_authority(":5120") == ("localhost", 5120)
|
||||
|
||||
|
||||
def test_split_authority_strips_ipv6_brackets() -> None:
|
||||
from zb_mom_ww_mxgateway.options import _split_authority
|
||||
|
||||
# Bracketed IPv6 with port — brackets must be removed for ssl.get_server_certificate
|
||||
assert _split_authority("[::1]:5120") == ("::1", 5120)
|
||||
# Bare bracketed IPv6 (no port) — default port 443
|
||||
assert _split_authority("[::1]") == ("::1", 443)
|
||||
# Scheme-prefixed bracketed IPv6
|
||||
assert _split_authority("grpc://[::1]:5120") == ("::1", 5120)
|
||||
|
||||
|
||||
def test_tofu_connect_failure_raises_transport_error() -> None:
|
||||
"""A failed cert pre-fetch surfaces the client's transport error type."""
|
||||
options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}")
|
||||
with pytest.raises(MxGatewayTransportError) as excinfo:
|
||||
create_channel(options)
|
||||
assert options.endpoint in str(excinfo.value)
|
||||
|
||||
|
||||
def test_require_certificate_validation_uses_system_trust() -> None:
|
||||
"""``require_certificate_validation`` must not attempt a TOFU pre-fetch."""
|
||||
# Pointing at a closed port: with system-trust the channel is created lazily
|
||||
# (no eager pre-fetch), so create_channel must succeed without connecting.
|
||||
options = ClientOptions(
|
||||
endpoint=f"127.0.0.1:{_free_port()}",
|
||||
require_certificate_validation=True,
|
||||
)
|
||||
channel = create_channel(options)
|
||||
assert isinstance(channel, grpc.aio.Channel)
|
||||
@@ -17,3 +17,6 @@
|
||||
# args through the GNU linker and reject `/STACK:`, are unaffected.
|
||||
[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/"
|
||||
|
||||
+14
-2
@@ -2,7 +2,16 @@
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.1.0"
|
||||
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]
|
||||
@@ -12,7 +21,10 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
publish = false
|
||||
authors = ["Joseph Doherty"]
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
publish = ["dohertj2-gitea"]
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
|
||||
@@ -76,6 +76,19 @@ types.
|
||||
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
||||
```
|
||||
|
||||
### TLS trust (pin-only)
|
||||
|
||||
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||
Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1
|
||||
exposes no public hook to inject a custom certificate verifier, so TLS over Rust
|
||||
is pin-only. A TLS connection requires either `--ca-file` /
|
||||
`ClientOptions::with_ca_file(...)` to pin a CA (export the gateway's self-signed
|
||||
certificate and pin it), or `--require-certificate-validation` /
|
||||
`with_require_certificate_validation(true)` to verify against the system trust
|
||||
roots. TLS with neither set fails `connect` with a clear, actionable error rather
|
||||
than accepting the certificate. See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
## Library Surface
|
||||
|
||||
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
||||
@@ -138,6 +151,50 @@ cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:500
|
||||
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -192,3 +249,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.0", registry = "dohertj2-gitea" }
|
||||
```
|
||||
|
||||
@@ -189,6 +189,25 @@ 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
|
||||
system trust roots.
|
||||
|
||||
With TLS enabled (`with_plaintext(false)`), no pinned CA, and certificate
|
||||
validation not required, `GatewayClient::connect` rejects the connection with a
|
||||
clear, actionable error pointing at `with_ca_file` /
|
||||
`require_certificate_validation` rather than silently accepting the certificate.
|
||||
The CLI exposes `--ca-file` and `--require-certificate-validation`.
|
||||
|
||||
## Streaming
|
||||
|
||||
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "mxgw-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "mxgw"
|
||||
|
||||
@@ -426,6 +426,11 @@ struct ConnectionArgs {
|
||||
ca_file: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
server_name_override: Option<String>,
|
||||
/// Verify the server certificate against the system trust roots even
|
||||
/// without a pinned CA. The Rust client's default is to require a CA
|
||||
/// file (see `--ca-file`); set this flag to use system roots instead.
|
||||
#[arg(long)]
|
||||
require_certificate_validation: bool,
|
||||
#[arg(long, default_value_t = 10)]
|
||||
connect_timeout_seconds: u64,
|
||||
#[arg(long, default_value_t = 30)]
|
||||
@@ -453,6 +458,9 @@ impl ConnectionArgs {
|
||||
if let Some(server_name_override) = &self.server_name_override {
|
||||
options = options.with_server_name_override(server_name_override);
|
||||
}
|
||||
if self.require_certificate_validation {
|
||||
options = options.with_require_certificate_validation(true);
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
//! 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;
|
||||
@@ -21,7 +19,7 @@ use crate::generated::mxaccess_gateway::v1::{
|
||||
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
|
||||
@@ -78,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)?;
|
||||
}
|
||||
|
||||
|
||||
+539
-20
@@ -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>>,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use tonic::transport::{Certificate, ClientTlsConfig};
|
||||
|
||||
use crate::auth::ApiKey;
|
||||
use crate::error::Error;
|
||||
|
||||
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
@@ -22,6 +26,7 @@ pub struct ClientOptions {
|
||||
api_key: Option<ApiKey>,
|
||||
plaintext: bool,
|
||||
ca_file: Option<PathBuf>,
|
||||
require_certificate_validation: bool,
|
||||
server_name_override: Option<String>,
|
||||
connect_timeout: Duration,
|
||||
call_timeout: Duration,
|
||||
@@ -38,6 +43,7 @@ impl ClientOptions {
|
||||
api_key: None,
|
||||
plaintext: true,
|
||||
ca_file: None,
|
||||
require_certificate_validation: false,
|
||||
server_name_override: None,
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
call_timeout: Duration::from_secs(30),
|
||||
@@ -67,6 +73,22 @@ impl ClientOptions {
|
||||
self
|
||||
}
|
||||
|
||||
/// Require TLS certificate verification even without a pinned CA. Default
|
||||
/// false: the gateway's self-signed certificate is accepted (internal-tool
|
||||
/// posture). Setting a CA file always verifies.
|
||||
///
|
||||
/// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a
|
||||
/// custom rustls verifier, so the Rust client cannot accept an arbitrary
|
||||
/// self-signed certificate the way the other clients do. With the default
|
||||
/// (false) and no pinned CA, [`crate::client::GatewayClient::connect`]
|
||||
/// rejects the TLS connection and asks for a CA file. Either pin a CA via
|
||||
/// [`ClientOptions::with_ca_file`] (the supported lenient path on Rust) or
|
||||
/// set this `true` to verify against the system trust roots.
|
||||
pub fn with_require_certificate_validation(mut self, require: bool) -> Self {
|
||||
self.require_certificate_validation = require;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the SNI/server name used during the TLS handshake. Useful
|
||||
/// when the dial-target host name does not match the certificate.
|
||||
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
|
||||
@@ -121,6 +143,12 @@ impl ClientOptions {
|
||||
self.ca_file.as_ref()
|
||||
}
|
||||
|
||||
/// Whether TLS certificate verification is required even without a pinned
|
||||
/// CA. See [`ClientOptions::with_require_certificate_validation`].
|
||||
pub fn require_certificate_validation(&self) -> bool {
|
||||
self.require_certificate_validation
|
||||
}
|
||||
|
||||
/// Optional SNI / server-name override for TLS handshakes.
|
||||
pub fn server_name_override(&self) -> Option<&str> {
|
||||
self.server_name_override.as_deref()
|
||||
@@ -147,6 +175,68 @@ impl ClientOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the [`ClientTlsConfig`] for a non-plaintext connection described by
|
||||
/// `options`, applying the lenient-default guard that is the **Rust
|
||||
/// pin-only exception**.
|
||||
///
|
||||
/// Returns `Ok(None)` when `options.plaintext()` is `true` (no TLS needed).
|
||||
/// Returns `Ok(Some(tls))` when a valid TLS config can be assembled.
|
||||
/// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned
|
||||
/// CA was provided and `require_certificate_validation` is `false`.
|
||||
///
|
||||
/// # Why this guard exists
|
||||
///
|
||||
/// `tonic` 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
||||
/// crate-private connector and exposes no hook for a custom
|
||||
/// `ServerCertVerifier`. The Rust client therefore cannot accept an arbitrary
|
||||
/// self-signed certificate the way the other language clients do. Rather than
|
||||
/// silently falling back to system-root verification (which always fails
|
||||
/// against a self-signed gateway certificate), we reject the configuration
|
||||
/// early with an actionable error.
|
||||
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
|
||||
if options.plaintext() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut tls = ClientTlsConfig::new();
|
||||
if let Some(server_name) = options.server_name_override() {
|
||||
tls = tls.domain_name(server_name.to_owned());
|
||||
}
|
||||
if let Some(ca_file) = options.ca_file() {
|
||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||
})?;
|
||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||
} else if !options.require_certificate_validation() {
|
||||
// Lenient-default fallback (Rust pin-only exception): tonic
|
||||
// 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
||||
// crate-private connector and exposes no hook for a custom
|
||||
// `ServerCertVerifier`, so — unlike the other clients — the
|
||||
// Rust client cannot accept an arbitrary self-signed cert. Pin
|
||||
// the gateway's CA instead, or opt into strict verification
|
||||
// against the system trust roots. We reject here rather than
|
||||
// silently verifying against system roots (which would fail a
|
||||
// self-signed gateway with a confusing handshake error).
|
||||
//
|
||||
// Note: a server-name override affects SNI (the hostname sent
|
||||
// in the TLS ClientHello) but does NOT pin trust. Overriding
|
||||
// the server name alone does not bypass certificate validation.
|
||||
return Err(Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: "TLS requested without a pinned CA. The Rust client cannot accept an \
|
||||
arbitrary self-signed certificate (tonic 0.13 exposes no custom \
|
||||
rustls verifier). Pin the gateway certificate with \
|
||||
ClientOptions::with_ca_file, or call \
|
||||
ClientOptions::with_require_certificate_validation(true) to verify \
|
||||
against the system trust roots. Note: a server-name override \
|
||||
affects SNI but does not pin trust."
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(Some(tls))
|
||||
}
|
||||
|
||||
impl Default for ClientOptions {
|
||||
fn default() -> Self {
|
||||
Self::new("http://127.0.0.1:5000")
|
||||
@@ -161,6 +251,10 @@ impl fmt::Debug for ClientOptions {
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.field("plaintext", &self.plaintext)
|
||||
.field("ca_file", &self.ca_file)
|
||||
.field(
|
||||
"require_certificate_validation",
|
||||
&self.require_certificate_validation,
|
||||
)
|
||||
.field("server_name_override", &self.server_name_override)
|
||||
.field("connect_timeout", &self.connect_timeout)
|
||||
.field("call_timeout", &self.call_timeout)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! TLS posture coverage for the Rust client.
|
||||
//!
|
||||
//! tonic 0.13.1's `ClientTlsConfig` exposes no hook for a custom rustls
|
||||
//! `ServerCertVerifier` (the verifier is built internally inside the
|
||||
//! crate-private `TlsConnector`), so the Rust client cannot implement the
|
||||
//! "accept any server certificate" lenient default the other clients use.
|
||||
//! Rust is therefore the documented **pin-only exception**: TLS without a
|
||||
//! pinned CA is rejected up front with a clear, actionable error, and
|
||||
//! supplying a CA file is the supported path. These tests pin that contract.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GalaxyClient, GatewayClient};
|
||||
|
||||
/// Drive `connect` to its error without requiring `GatewayClient: Debug`
|
||||
/// (the success arm is dropped explicitly so `unwrap_err` is unnecessary).
|
||||
async fn connect_err(options: ClientOptions) -> Error {
|
||||
match GatewayClient::connect(options).await {
|
||||
Ok(_client) => panic!("connect unexpectedly succeeded against a dead TLS address"),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_without_ca_is_rejected_with_actionable_error_by_default() {
|
||||
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||
.with_plaintext(false)
|
||||
.with_connect_timeout(Duration::from_millis(200));
|
||||
|
||||
let error = connect_err(options).await;
|
||||
|
||||
let Error::InvalidEndpoint { detail, .. } = error else {
|
||||
panic!("expected InvalidEndpoint, got {error:?}");
|
||||
};
|
||||
// The message must point the caller at the supported remedy (pin a CA)
|
||||
// and name the opt-in escape hatch.
|
||||
assert!(
|
||||
detail.contains("ca_file") || detail.contains("CA"),
|
||||
"error should instruct the user to pass a CA file: {detail}"
|
||||
);
|
||||
assert!(
|
||||
detail.contains("require_certificate_validation"),
|
||||
"error should mention the require_certificate_validation opt-in: {detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_with_require_certificate_validation_does_not_short_circuit() {
|
||||
// With strict verification opted in, the no-CA guard must not fire; the
|
||||
// connect attempt instead proceeds to the transport (and fails to reach
|
||||
// the dead address) rather than returning the "CA required" guard error.
|
||||
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||
.with_plaintext(false)
|
||||
.with_require_certificate_validation(true)
|
||||
.with_connect_timeout(Duration::from_millis(200));
|
||||
|
||||
let error = connect_err(options).await;
|
||||
|
||||
assert!(
|
||||
!matches!(&error, Error::InvalidEndpoint { detail, .. }
|
||||
if detail.contains("require_certificate_validation")),
|
||||
"strict verification must bypass the no-CA guard, got {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_with_ca_file_is_permitted_and_proceeds_past_the_guard() {
|
||||
// Pinning a CA is the supported TLS path: the no-CA guard must not fire.
|
||||
// We hand it a readable PEM file; construction proceeds past the guard
|
||||
// and only fails later at the transport (dead address / handshake).
|
||||
let ca_path = std::env::temp_dir().join("mxgw-rust-tls-ca-fixture.pem");
|
||||
std::fs::write(&ca_path, SELF_SIGNED_CA_PEM).unwrap();
|
||||
|
||||
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||
.with_plaintext(false)
|
||||
.with_ca_file(&ca_path)
|
||||
.with_connect_timeout(Duration::from_millis(200));
|
||||
|
||||
let error = connect_err(options).await;
|
||||
|
||||
let _ = std::fs::remove_file(&ca_path);
|
||||
|
||||
assert!(
|
||||
!matches!(&error, Error::InvalidEndpoint { detail, .. }
|
||||
if detail.contains("require_certificate_validation")),
|
||||
"pinning a CA must bypass the no-CA guard, got {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive `GalaxyClient::connect` to its error (mirrors `connect_err` above).
|
||||
async fn galaxy_connect_err(options: ClientOptions) -> Error {
|
||||
match GalaxyClient::connect(options).await {
|
||||
Ok(_client) => {
|
||||
panic!("GalaxyClient::connect unexpectedly succeeded against a dead TLS address")
|
||||
}
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn galaxy_tls_without_ca_is_rejected_with_actionable_error_by_default() {
|
||||
// GalaxyClient::connect must apply the same TLS guard as GatewayClient —
|
||||
// TLS without a pinned CA (and without require_certificate_validation)
|
||||
// returns a clear, actionable InvalidEndpoint error.
|
||||
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||
.with_plaintext(false)
|
||||
.with_connect_timeout(Duration::from_millis(200));
|
||||
|
||||
let error = galaxy_connect_err(options).await;
|
||||
|
||||
let Error::InvalidEndpoint { detail, .. } = error else {
|
||||
panic!("expected InvalidEndpoint, got {error:?}");
|
||||
};
|
||||
assert!(
|
||||
detail.contains("ca_file") || detail.contains("CA"),
|
||||
"error should instruct the user to pass a CA file: {detail}"
|
||||
);
|
||||
assert!(
|
||||
detail.contains("require_certificate_validation"),
|
||||
"error should mention the require_certificate_validation opt-in: {detail}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A throwaway self-signed CA certificate (PEM). Only needs to parse as a
|
||||
/// PEM trust root so the CA-pinning path is exercised past the guard.
|
||||
const SELF_SIGNED_CA_PEM: &str = "-----BEGIN CERTIFICATE-----
|
||||
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
||||
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
||||
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
||||
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
||||
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
||||
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
||||
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
||||
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
||||
6MF9+Yw1Yy0t
|
||||
-----END CERTIFICATE-----
|
||||
";
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-24 |
|
||||
| Commit reviewed | `42b0037` |
|
||||
| Status | Re-reviewed |
|
||||
| Open findings | 5 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -551,7 +551,7 @@ Client.Java-001..031 are unchanged.
|
||||
| Severity | High |
|
||||
| Category | Documentation & comments |
|
||||
| Location | `clients/java/README.md:182-183` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Commit `8738735` ("clients: document StreamAlarms + AcknowledgeAlarm in each README") added two new gradle invocations to the CLI Usage block:
|
||||
|
||||
@@ -569,6 +569,8 @@ A user copying either invocation from the README hits a picocli parse error imme
|
||||
|
||||
**Recommendation:** Drop the `--session-id <id>` token from both documented invocations, and change `--alarm-reference` to `--reference` in the `acknowledge-alarm` line. Optionally also add `--filter-prefix` to the `stream-alarms` example so readers see the scoping option, and align README option names with the actual CLI by either renaming the CLI option `--reference` → `--alarm-reference` (matches the proto `alarm_full_reference` field semantically) or leaving as is and only fixing the README. Add a small `MxGatewayCliTests` parse-only assertion for both subcommands that exercises every option flag to prevent the same drift the next time the CLI surface or README is touched.
|
||||
|
||||
**Resolution:** 2026-05-24 — Confirmed root cause against `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1174-1182,1248-1258`: `StreamAlarmsCommand` exposes only `--filter-prefix` / `--limit` and `AcknowledgeAlarmCommand` exposes `--reference` / `--comment` / `--operator` — neither has a `--session-id` option and `acknowledge-alarm` has no `--alarm-reference` option, so both documented invocations failed picocli parse at the first unknown option. Fixed `clients/java/README.md:182-183` by dropping the `--session-id <id>` token from both lines, replacing it with `--filter-prefix Galaxy` on the `stream-alarms` example so readers see the actual scoping flag, and changing `--alarm-reference` to `--reference` on the `acknowledge-alarm` example. Added `MxGatewayCli.commandLine(...)` to package-private visibility (was `private`) so the test can drive the production picocli `CommandLine` directly without executing the command body. Regression tests in `MxGatewayCliTests`: `readmeDocumentedStreamAlarmsExampleParsesCleanly` and `readmeDocumentedAcknowledgeAlarmExampleParsesCleanly` pin the exact token list documented in the README and assert `commandLine.parseArgs(...)` returns without throwing a `picocli.CommandLine.ParameterException`. TDD red phase: before the README fix the previously-documented tokens (`--session-id <id>` + `--alarm-reference ...`) would have thrown `Unknown option: '--session-id'` / `Unknown option: '--alarm-reference'` at parse time; the new tests pass against the corrected README and would fail the next time someone drifts the documented surface from the actual CLI options.
|
||||
|
||||
### Client.Java-033
|
||||
|
||||
| Field | Value |
|
||||
@@ -576,7 +578,7 @@ A user copying either invocation from the README hits a picocli parse error imme
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1078-1098` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `StreamAlarmsCommand.call()` allocates a bounded `ArrayBlockingQueue<Object>(1024)` and the gRPC observer publishes each `AlarmFeedMessage` via `queue.offer(value)`:
|
||||
|
||||
@@ -594,6 +596,8 @@ The library-side `MxEventStream` (Client.Java-002 resolution) and `DeployEventSt
|
||||
|
||||
**Recommendation:** Either (a) wrap the gRPC observer in the existing `MxEventStream`-style adaptor that calls `subscription.cancel()` and queues an exception on `queue.offer` returning `false`, then surface that exception from the drain loop — mirroring `MxEventStream.observer().onNext`'s overflow branch; or (b) reuse the library-side fail-fast plumbing by promoting `MxEventStream` (or extracting its terminal-state base) into a public `MxAlarmFeedStream` and have `MxGatewayClient.streamAlarms` return that instead of a bare subscription handle. Option (b) lines up with Client.Java-036 (deduplicate the subscription class family). Add a CLI regression test that overflows the bounded queue and asserts a non-zero exit / overflow exception, mirroring `MxGatewayMediumFindingsTests.eventStreamOverflowExceptionSurvivesASubsequentClose`.
|
||||
|
||||
**Resolution:** 2026-05-24 — Confirmed root cause at `MxGatewayCli.java` `StreamAlarmsCommand.call()`: the observer's `onNext` did `queue.offer(value)` and ignored the boolean return, so a 1024-element queue would silently drop messages past capacity. The same silent-drop affected the `onCompleted` branch (which `offer`s `ALARM_FEED_END`) once the queue was full, deadlocking the consumer since the drain loop never sees END. Took option (a) — minimal change that matches `MxEventStream`'s overflow branch. The fix: detect a failed `offer` inside `onNext`, call `subscription.cancel()` (via an `AtomicReference<MxGatewayAlarmFeedSubscription>` published immediately after `client.streamAlarms` returns), `queue.clear()`, then `queue.offer(IllegalStateException("stream-alarms queue overflowed (capacity 1024); consumer too slow"))` followed by `queue.offer(ALARM_FEED_END)`. The existing drain-loop `Throwable`-branch then surfaces the overflow as a thrown `IllegalStateException` from `call()`, which picocli reports as a non-zero CLI exit. Option (b) (promoting `MxEventStream` to a public alarm-feed stream) was considered and rejected for this change — it would change the public SDK surface; Client.Java-036's refactor handles deduplication at the subscription layer instead. Regression test: `MxGatewayCliTests.streamAlarmsCommandFailsFastOnQueueOverflow` — drives an `OverflowingFakeClient` whose `streamAlarms` synchronously pushes 2000 messages to the observer (exceeding the 1024 buffer), then asserts `run.exitCode() != 0`. TDD red phase confirmed deterministically: before the fix the test deadlocked (the buggy `offer` silently dropped both the overflowing alarms AND the `ALARM_FEED_END` sentinel that arrived after the queue filled, so the drain loop's `queue.take()` blocked forever); the background gradle run had to be killed with `TaskStop`. After the fix the same test exits in <1 second with the overflow exception propagating through picocli.
|
||||
|
||||
### Client.Java-034
|
||||
|
||||
| Field | Value |
|
||||
@@ -601,7 +605,7 @@ The library-side `MxEventStream` (Client.Java-002 resolution) and `DeployEventSt
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:182-198` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `BatchCommand.call()` reads one CLI invocation per stdin line and tokenises with:
|
||||
|
||||
@@ -622,6 +626,8 @@ The current `MxGatewayCliTests` test set (`batchCommandExecutesVersionAndEmitsEo
|
||||
|
||||
**Recommendation:** Replace `line.trim().split("\\s+")` with a real shell-style tokeniser that honours single and double quotes and backslash escapes — `picocli.CommandLine.ArgumentParser` doesn't ship one, but Apache Commons Exec's `CommandLine.translateCommandline(String)`, JDK 21's `java.util.spi.ToolProvider` argument parsing, or a small hand-written state machine all work. Cross-check the .NET / Go / Rust / Python `batch` implementations in the same change so all five clients use the same tokenisation; document the contract in the protocol comment in `MxGatewayCli.java` and in `scripts/run-client-e2e-tests.ps1`. Add a CLI test that feeds `acknowledge-alarm --comment "with spaces"` through `batch` and asserts the `--comment` value reaches the gateway as `"with spaces"`.
|
||||
|
||||
**Resolution:** 2026-05-24 — Confirmed root cause: `BatchCommand.call()` at the per-line loop used `line.trim().split("\\s+")` which has no quote handling. Replaced with a new package-private `MxGatewayCli.tokenizeBatchLine(String)` static helper — a hand-rolled POSIX-style shell tokenizer (no new dependency added) that honours: (a) double-quoted runs `"..."` with `\\`, `\"`, and `\n` escapes inside; (b) single-quoted runs `'...'` taken literally with no escapes (POSIX rule); (c) backslash escapes for any single character outside quotes (so `needs\ verification` is one token); (d) whitespace runs outside quotes separate tokens; (e) explicit `IllegalArgumentException` on unterminated quote or trailing backslash so the batch loop surfaces it as a JSON error instead of emitting wrong args. The `BatchCommand` per-line tokenisation now calls `tokenizeBatchLine(line)` and treats an empty-array result as a blank line (skip). Behaviour for whitespace-only input is unchanged. The cross-client `batch` audit (.NET / Go / Rust / Python) is out of scope for this Java-focused finding and tracked separately. Regression tests in `MxGatewayCliTests`: (a) `batchCommandTokenisesDoubleQuotedArgumentWithEmbeddedSpaces` — `--comment "needs verification"` round-trips intact; (b) `batchCommandTokenisesSingleQuotedArgumentWithEmbeddedSpaces` — single-quoted variant; (c) `batchCommandTokenisesBackslashEscapedSpaceOutsideQuotes` — `needs\ verification` outside quotes; (d) `batchCommandPreservesEmptyQuotedArgument` — `""` parses to an empty-string argument; (e) `batchCommandSupportsBackslashEscapedQuoteInsideDoubleQuotes` — `\"inner\"` survives the inner quotes. TDD red phase confirmed: all five tests failed against the original `split("\\s+")` implementation; after the fix all five pass.
|
||||
|
||||
### Client.Java-035
|
||||
|
||||
| Field | Value |
|
||||
@@ -629,7 +635,7 @@ The current `MxGatewayCliTests` test set (`batchCommandExecutesVersionAndEmitsEo
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientSessionTests.java` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** Commit `8a0c59d` added `MxGatewayClient.streamAlarms(StreamAlarmsRequest, StreamObserver<AlarmFeedMessage>)` and a new public `MxGatewayAlarmFeedSubscription` class. No library-side test exercises either: a grep for `streamAlarms` across `zb-mom-ww-mxgateway-client/src/test/...` returns zero matches. The CLI tests (`MxGatewayCliTests.streamAlarmsCommand*`) exercise the path end-to-end, but they route through a `FakeClient.streamAlarms` override that bypasses the production `subscription.wrap(observer)` glue and the `withStreamDeadline(rawAsyncStub()).streamAlarms(...)` call. A regression to either — forgetting `.wrap(observer)`, dropping the deadline interceptor, misnaming the request — would compile and pass the CLI tests but break against a real gateway.
|
||||
|
||||
@@ -637,6 +643,8 @@ This is the same coverage gap pattern as Client.Java-030 (no fixture test for `Q
|
||||
|
||||
**Recommendation:** Add `streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages` to `MxGatewayClientSessionTests` (in-process gRPC via the existing `InProcessGateway` + `TestGatewayService` fixture): override `TestGatewayService.streamAlarms` to capture the inbound `StreamAlarmsRequest` and emit one `active_alarm` snapshot, one `snapshot_complete`, and one `transition`, then complete. Call `MxGatewayClient.streamAlarms`, drain the observer via a `CountDownLatch`, and assert (a) the server observed the `alarm_filter_prefix`, (b) all three messages arrived in order with the expected payload-case, and (c) `MxGatewayAlarmFeedSubscription.cancel()` aborts the call (latch via `ServerCallStreamObserver.setOnCancelHandler`, mirroring the Client.Java-015 cancellation regression). Optionally also cover the cancel-before-beforeStart race that `MxGatewayAlarmFeedSubscription.wrap` handles, mirroring `mxEventStreamCloseBeforeBeforeStartCancelsStream`.
|
||||
|
||||
**Resolution:** 2026-05-24 — Confirmed the coverage gap: a grep across `zb-mom-ww-mxgateway-client/src/test/...` for `streamAlarms` returned zero matches; the CLI-only test routed through `FakeClient.streamAlarms` which bypassed both the production `subscription.wrap(observer)` and the `withStreamDeadline(rawAsyncStub()).streamAlarms(...)` gRPC call. Added `streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages` to `MxGatewayClientSessionTests` in the same shape as `queryActiveAlarmsForwardsRequestAndStreamsSnapshots` (Client.Java-030 resolved this way). The test overrides `TestGatewayService.streamAlarms` to capture the inbound `StreamAlarmsRequest`, register a `serverCancelled` latch via `(ServerCallStreamObserver<AlarmFeedMessage>) responseObserver).setOnCancelHandler(...)`, then emit three messages: an `active_alarm` snapshot, a `snapshot_complete` sentinel, and a `transition`. It deliberately does NOT call `onCompleted()` so the call remains open for the cancellation assertion. The test then calls `MxGatewayClient.streamAlarms` against the in-process gateway, drains the wrapped observer via a `threeReceived` `CountDownLatch`, and asserts (a) the server observed `alarm_filter_prefix=Tank01`, (b) all three messages arrived in order with the expected payload-case (`ACTIVE_ALARM`, `SNAPSHOT_COMPLETE`, `TRANSITION`) and payload values (`Tank01.Level.HiHi`, transition kind `ACKNOWLEDGE`), and (c) `subscription.cancel()` causes the server's on-cancel handler to fire within 5 s (proves cancellation propagates through the production `subscription.wrap(observer)` glue, not just the CLI fake). TDD red phase: temporarily replaced the production `MxGatewayClient.streamAlarms` body with `withStreamDeadline(rawAsyncStub()).streamAlarms(request, observer);` (dropping the `subscription.wrap(observer)` indirection); the test failed at the `serverCancelled.await` assertion because cancellation was no longer wired to the underlying gRPC call. Restoring the production glue turned the build green.
|
||||
|
||||
### Client.Java-036
|
||||
|
||||
| Field | Value |
|
||||
@@ -644,7 +652,7 @@ This is the same coverage gap pattern as Client.Java-030 (no fixture test for `Q
|
||||
| Severity | Low |
|
||||
| Category | Code organization & conventions |
|
||||
| Location | `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayAlarmFeedSubscription.java`, `MxGatewayEventSubscription.java`, `MxGatewayActiveAlarmsSubscription.java`, `DeployEventSubscription.java` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `MxGatewayAlarmFeedSubscription` is a structural near-copy of `MxGatewayEventSubscription` — same `AtomicReference<ClientCallStreamObserver<…>>` + `AtomicBoolean cancelled` field shape, the same `wrap(observer)` returning a `ClientResponseObserver` that stores `requestStream` in `beforeStart`, the same close-before-beforeStart race handling that Client.Java-014 originally fixed for `MxEventStream`, and the same `cancel()`+`close()` idempotency contract. The four subscription classes (`MxGatewayEventSubscription`, `MxGatewayActiveAlarmsSubscription`, `MxGatewayAlarmFeedSubscription`, `DeployEventSubscription`) are now ~60-line near-clones differing only in the request/response generic parameters and the `cancel` message string.
|
||||
|
||||
@@ -652,4 +660,6 @@ This is the same maintenance-hazard pattern Client.Java-009 / Client.Java-016 id
|
||||
|
||||
**Recommendation:** Extract a package-private abstract base, e.g. `MxGatewayStreamSubscription<TRequest>`, holding the `AtomicReference` / `AtomicBoolean` pair, the `cancel()` / `close()` implementation, and a `ClientResponseObserver` factory parameterised by the cancel-message string and the response observer. Have all four subscription classes extend it. Behaviour-only refactor — no public API change, existing tests cover the contract.
|
||||
|
||||
**Resolution:** 2026-05-24 — Extracted a package-private abstract base `MxGatewayStreamSubscription<TRequest, TResponse> implements AutoCloseable` (new file `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayStreamSubscription.java`). It holds the shared `AtomicReference<ClientCallStreamObserver<TRequest>>` and `AtomicBoolean cancelled` pair, the `wrap(StreamObserver<TResponse>)` factory that returns a `ClientResponseObserver` with the Client.Java-014 close-before-beforeStart fix baked in, the `cancel()` / `close()` implementation, and an immutable `cancelMessage` injected by the subclass constructor. The four prior 60-line near-clones (`MxGatewayEventSubscription`, `MxGatewayAlarmFeedSubscription`, `MxGatewayActiveAlarmsSubscription`, `DeployEventSubscription`) collapse to ~10-line subclasses that only declare their `<Request, Response>` type parameters and supply the cancel-message string to `super(...)`. Public API surface is preserved: each subclass remains a `public final class` with a public no-arg constructor (the constructor was implicit on the original classes; I made it explicit `public` on the subclasses so the existing CLI `FakeClient.streamAlarms` in a different package can still `new MxGatewayAlarmFeedSubscription()`). The `wrap(...)` method is `final` and package-private on the base — same accessibility the four subclasses had before — so production callers in `MxGatewayClient`/`GalaxyRepositoryClient` see no change. New test file `MxGatewayStreamSubscriptionContractTests` exercises the lifecycle/cancellation contract identically across all four subclasses (16 tests, four per scenario): (a) cancel-before-beforeStart eagerly cancels the stream once it attaches with the subclass-specific message, (b) cancel-after-beforeStart forwards directly to the stream, (c) `close()` delegates to `cancel()`, (d) the wrapped observer forwards `onNext`/`onError`/`onCompleted` verbatim, and a compile-time `typeBoundsCheck` helper that asserts each subclass still binds its `<Req, Resp>` parameters to the right proto types. TDD red phase confirmed: temporarily breaking one subclass's `super(...)` message to `"BROKEN MESSAGE"` made the contract test for that subclass fail with `expected: <client cancelled alarm feed> but was: <BROKEN MESSAGE>`; restoring the correct value turned all 16 contract tests green. Future fixes to the shared lifecycle now live in one place — the next Client.Java-014/021-style race fix cannot drift across the four classes.
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-24 |
|
||||
| Commit reviewed | `42b0037` |
|
||||
| Status | Re-reviewed |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -494,7 +494,7 @@ The Write parity test (IntegrationTests-012's resolution) added exactly this ass
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs:57-84` (`ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The new regression test for IntegrationTests-022 builds an "isolated" start directory under `Path.GetTempPath()` (e.g. `C:\Users\<user>\AppData\Local\Temp\<random>\nested` on Windows) and calls `ResolveRepositoryRoot(isolatedStart)`, asserting an `InvalidOperationException` is thrown. The walker walks every parent — `<random>`, `Temp`, `Local`, `AppData`, `<user>`, `Users`, `C:\` — and stops only when it either finds a repository root marker or runs out of parents. The test silently assumes none of those ancestor directories satisfies `IsRepositoryRoot` (a `src/` subdirectory next to `.git` / `*.sln` / `*.slnx`). The assumption is environment-dependent:
|
||||
|
||||
@@ -504,3 +504,5 @@ The Write parity test (IntegrationTests-012's resolution) added exactly this ass
|
||||
The current dev box layout (`C:\Users\dohertj2\Desktop\mxaccessgw`) is safe because Temp is at `C:\Users\dohertj2\AppData\Local\Temp` and the walker exits at `C:\` without ever encountering `src/`. The fragility is invisible on this machine and only surfaces if the test ever runs in CI / on a contributor box with a less hermetic file-system layout.
|
||||
|
||||
**Recommendation:** Isolate the walker from any ambient ancestor by either (a) constructing an `isolatedRoot` directly under a drive root and pointing the walker at a chain entirely under it (e.g. create `<isolatedRoot>\level1\level2\level3` and start the walk at `level3`, then assert the throw — the walker stops at the drive root regardless of what is on it), (b) refactoring `ResolveRepositoryRoot` to accept an injectable `stopBoundary` parameter for tests and pass `isolatedRoot` as the boundary, or (c) replacing the `Assert.Throws` shape with an explicit upward-walk check that the test owns. Option (a) is the smallest change: prepend a sentinel — e.g. create a dummy `<isolatedRoot>\sentinel-no-markers` and assert nothing about Temp ancestors — and pass the test only when the walker reaches that sentinel without finding a marker. The current shape is acceptable on the documented dev box but should not be the sole regression coverage for IntegrationTests-022.
|
||||
|
||||
**Resolution:** Resolved 2026-05-24 — Took option (b) (inject a stop-boundary) because option (a) does not actually solve the leak: a sentinel chain under `Path.GetTempPath()` still leaves the walker free to ascend past it into Temp / AppData / Users / C:\, so any ambient ancestor with `src/` + `.git`/`.sln`/`.slnx` still wins. Added an optional `stopBoundary` parameter to `IntegrationTestEnvironment.ResolveRepositoryRoot(string startDirectory, string? stopBoundary = null)`. When supplied, the walker checks the boundary for markers and then stops, refusing to ascend past it; production callers (the `MXGATEWAY_LIVE_MXACCESS_WORKER_EXE` resolution path) continue to pass `null` so the walk to drive-root behavior is unchanged. Updated both existing tests (`ResolveRepositoryRoot_AcceptsGitWorktreeFile` and `ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) to pass their owned temp directory as the boundary, sealing the walker inside a chain the test fully controls. Added a new regression test `ResolveRepositoryRoot_StopBoundary_IsolatesWalkerFromAmbientAncestorMarkers` that deliberately constructs an outer marker-bearing ancestor (`outerRoot/src` + `outerRoot/.git`), an inner boundary, and an isolated start beneath the boundary; first asserts that without the boundary the walker leaks up to `outerRoot` (the precise IntegrationTests-025 failure mode), then asserts that *with* the boundary the same call throws — proving the boundary is the load-bearing isolation. TDD red/green confirmed: the new regression test fails against the pre-fix walker (`Assert.Throws() Failure: No exception was thrown`) and passes once the boundary handling is restored. Re-ran the full `IntegrationTestEnvironmentTests` slice with `TMP` / `TEMP` redirected under a deliberately constructed `<temp>\fake-repo-ancestor` directory carrying `src/` and a `.git` file — the original flake repro from the finding — and confirmed all 5 tests pass (the same redirection produced `Assert.Throws() Failure` on the pre-fix code). Build: 0 warnings / 0 errors.
|
||||
|
||||
+15
-16
@@ -12,13 +12,13 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
||||
|---|---|---|---|---|---|---|
|
||||
| [Client.Dotnet](Client.Dotnet/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 21 |
|
||||
| [Client.Go](Client.Go/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 27 |
|
||||
| [Client.Java](Client.Java/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 5 | 36 |
|
||||
| [Client.Java](Client.Java/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 36 |
|
||||
| [Client.Python](Client.Python/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 26 |
|
||||
| [Client.Rust](Client.Rust/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 29 |
|
||||
| [Contracts](Contracts/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 17 |
|
||||
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 1 | 25 |
|
||||
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 25 |
|
||||
| [Server](Server/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 50 |
|
||||
| [Tests](Tests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 5 | 31 |
|
||||
| [Tests](Tests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 31 |
|
||||
| [Worker](Worker/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 25 |
|
||||
| [Worker.Tests](Worker.Tests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 30 |
|
||||
|
||||
@@ -26,19 +26,7 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
||||
|
||||
Findings with status `Open` or `In Progress`, ordered by severity.
|
||||
|
||||
| ID | Severity | Category | Location | Description |
|
||||
|---|---|---|---|---|
|
||||
| Client.Java-032 | High | Documentation & comments | `clients/java/README.md:182-183` | Commit `8738735` ("clients: document StreamAlarms + AcknowledgeAlarm in each README") added two new gradle invocations to the CLI Usage block: ``` gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-ke… |
|
||||
| Client.Java-033 | Medium | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1078-1098` | `StreamAlarmsCommand.call()` allocates a bounded `ArrayBlockingQueue<Object>(1024)` and the gRPC observer publishes each `AlarmFeedMessage` via `queue.offer(value)`: ``` BlockingQueue<Object> queue = new ArrayBlockingQueue<>(1024); … @Over… |
|
||||
| Client.Java-034 | Medium | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:182-198` | `BatchCommand.call()` reads one CLI invocation per stdin line and tokenises with: ``` String[] args = line.trim().split("\\s+"); … int exitCode = cmd.execute(args); ``` `split("\\s+")` does no shell-quoting parsing — it just splits on whit… |
|
||||
| Tests-027 | Medium | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:199-240`, `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:8,73,246-251` | The review brief explicitly flagged `MxAccessGatewayServiceTests.StreamEvents_WhenEventIsWritten_RecordsSendDuration` as a known flake that "passed solo on rerun". The root cause is the `MeterListener` subscribes by `instrument.Meter.Name… |
|
||||
| Client.Java-035 | Low | Testing coverage | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientSessionTests.java` | Commit `8a0c59d` added `MxGatewayClient.streamAlarms(StreamAlarmsRequest, StreamObserver<AlarmFeedMessage>)` and a new public `MxGatewayAlarmFeedSubscription` class. No library-side test exercises either: a grep for `streamAlarms` across `… |
|
||||
| Client.Java-036 | Low | Code organization & conventions | `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayAlarmFeedSubscription.java`, `MxGatewayEventSubscription.java`, `MxGatewayActiveAlarmsSubscription.java`, `DeployEventSubscription.java` | `MxGatewayAlarmFeedSubscription` is a structural near-copy of `MxGatewayEventSubscription` — same `AtomicReference<ClientCallStreamObserver<…>>` + `AtomicBoolean cancelled` field shape, the same `wrap(observer)` returning a `ClientResponse… |
|
||||
| IntegrationTests-025 | Low | Correctness & logic bugs | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs:57-84` (`ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) | The new regression test for IntegrationTests-022 builds an "isolated" start directory under `Path.GetTempPath()` (e.g. `C:\Users\<user>\AppData\Local\Temp\<random>\nested` on Windows) and calls `ResolveRepositoryRoot(isolatedStart)`, asser… |
|
||||
| Tests-028 | Low | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:466-496,802-807`, `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:216-253` | The new `KillWorkerAsync_KillsWorkerAndRemovesSession` (line 466) and `KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound` (line 486) pin the new kill-path entry, but they do not pin the `reason` argument propagating through the chai… |
|
||||
| Tests-029 | Low | Error handling & resilience | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs:61-106,139-222`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs:77-125` | The new `DashboardSessionAdminServiceTests` covers the happy path and the viewer-denial path for both `CloseSessionAsync` and `KillWorkerAsync`, plus `CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError` for the close-side `SessionNo… |
|
||||
| Tests-030 | Low | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs:115-163`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:146-177` | The three new `DeleteAsync_*` fixtures cover unauthorised user, success path with audit, and store-refuses-with-friendly-error. They do not exercise two production behaviours: (1) `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` (line 1… |
|
||||
| Tests-031 | Low | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs:22-61` | `ExecuteAsync_WhenSnapshotServiceThrowsOnce_ReconnectsAfterDelay` records `startedAt = DateTimeOffset.UtcNow` *before* calling `publisher.StartAsync(...)`, then asserts `secondSubscribeAt - startedAt >= reconnectDelay - 10ms` (line 59). Th… |
|
||||
_No pending findings._
|
||||
|
||||
## Closed findings
|
||||
|
||||
@@ -49,6 +37,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Server-001 | Critical | Resolved | Security | `src/MxGateway.Server/GatewayApplication.cs:147-149`, `src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs:55-58`, `src/MxGateway.Server/Dashboard/Components/Routes.razor:1-15` |
|
||||
| Client.Go-001 | High | Resolved | Correctness & logic bugs | `clients/go/mxgateway/errors.go:88-93`, `clients/go/mxgateway/errors.go:117-128` |
|
||||
| Client.Java-013 | High | Resolved | Testing coverage | `clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java:212-304`, `clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java:1214-1244` |
|
||||
| Client.Java-032 | High | Resolved | Documentation & comments | `clients/java/README.md:182-183` |
|
||||
| Client.Python-018 | High | Resolved | Code organization & conventions | `clients/python/pyproject.toml:11` |
|
||||
| Client.Python-022 | High | Resolved | Documentation & comments | `clients/python/README.md:201-202`, `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:389-420` |
|
||||
| Client.Rust-001 | High | Resolved | mxaccessgw conventions | `clients/rust/src/options.rs:98,143` |
|
||||
@@ -86,6 +75,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Client.Java-021 | Medium | Resolved | Concurrency & thread safety | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/DeployEventStream.java:96-135` |
|
||||
| Client.Java-027 | Medium | Resolved | Documentation & comments | `clients/java/README.md:36,107-175,185,205,220`, `clients/java/JavaClientDesign.md:195-211` |
|
||||
| Client.Java-028 | Medium | Resolved | Documentation & comments | `clients/java/JavaClientDesign.md:23-27` |
|
||||
| Client.Java-033 | Medium | Resolved | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1078-1098` |
|
||||
| Client.Java-034 | Medium | Resolved | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:182-198` |
|
||||
| Client.Python-003 | Medium | Resolved | Error handling & resilience | `clients/python/src/mxgateway/client.py:125-137,155-173` |
|
||||
| Client.Python-005 | Medium | Resolved | Performance & resource management | `clients/python/src/mxgateway/galaxy.py:117-140` |
|
||||
| Client.Python-009 | Medium | Resolved | Testing coverage | `clients/python/tests/` |
|
||||
@@ -130,6 +121,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Tests-016 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs:29-41,115-124` |
|
||||
| Tests-020 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs:275-347`, `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:803-829` |
|
||||
| Tests-026 | Medium | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs`, `src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs:123-126` |
|
||||
| Tests-027 | Medium | Resolved | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:199-240`, `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:8,73,246-251` |
|
||||
| Worker-004 | Medium | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:565-588` |
|
||||
| Worker-005 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:205-258` (production alarm poll loop) |
|
||||
| Worker-006 | Medium | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:117-124`, `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:386-491` |
|
||||
@@ -205,6 +197,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Client.Java-029 | Low | Resolved | Documentation & comments | `clients/java/README.md:208-209` |
|
||||
| Client.Java-030 | Low | Resolved | Testing coverage | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/` |
|
||||
| Client.Java-031 | Low | Resolved | mxaccessgw conventions | `clients/java/README.md:13,17,26` |
|
||||
| Client.Java-035 | Low | Resolved | Testing coverage | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientSessionTests.java` |
|
||||
| Client.Java-036 | Low | Resolved | Code organization & conventions | `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayAlarmFeedSubscription.java`, `MxGatewayEventSubscription.java`, `MxGatewayActiveAlarmsSubscription.java`, `DeployEventSubscription.java` |
|
||||
| Client.Python-001 | Low | Resolved | Documentation & comments | `clients/python/pyproject.toml:8,25`, `clients/python/src/mxgateway_cli/commands.py:25` |
|
||||
| Client.Python-002 | Low | Resolved | Code organization & conventions | `clients/python/src/mxgateway/__init__.py:27` |
|
||||
| Client.Python-004 | Low | Resolved | Correctness & logic bugs | `clients/python/src/mxgateway_cli/commands.py:386,402-404` |
|
||||
@@ -268,6 +262,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| IntegrationTests-022 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironment.cs:103-138` (`ResolveRepositoryRoot` / `IsRepositoryRoot`) |
|
||||
| IntegrationTests-023 | Low | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs:14-29` |
|
||||
| IntegrationTests-024 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs` (`NullDashboardEventBroadcaster` private class at end of file) |
|
||||
| IntegrationTests-025 | Low | Resolved | Correctness & logic bugs | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs:57-84` (`ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) |
|
||||
| Server-007 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs:55-70` |
|
||||
| Server-008 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:111-134,160-189` |
|
||||
| Server-009 | Low | Resolved | Error handling & resilience | `src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs:15-32` |
|
||||
@@ -318,6 +313,10 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
||||
| Tests-023 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs:334-374` |
|
||||
| Tests-024 | Low | Resolved | Testing coverage | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:713-730,784-801,859-876`, `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs` |
|
||||
| Tests-025 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs:285-289`, `src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:417-421` |
|
||||
| Tests-028 | Low | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:466-496,802-807`, `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:216-253` |
|
||||
| Tests-029 | Low | Resolved | Error handling & resilience | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs:61-106,139-222`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs:77-125` |
|
||||
| Tests-030 | Low | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs:115-163`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:146-177` |
|
||||
| Tests-031 | Low | Resolved | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs:22-61` |
|
||||
| Worker-009 | Low | Resolved | Performance & resource management | `src/MxGateway.Worker/Ipc/WorkerFrameReader.cs:31,49`, `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:57-58` |
|
||||
| Worker-010 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Conversion/VariantConverter.cs:204-226` |
|
||||
| Worker-011 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeClient.cs:169-171` |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-24 |
|
||||
| Commit reviewed | `42b0037` |
|
||||
| Status | Re-reviewed |
|
||||
| Open findings | 5 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -488,12 +488,14 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:199-240`, `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:8,73,246-251` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The review brief explicitly flagged `MxAccessGatewayServiceTests.StreamEvents_WhenEventIsWritten_RecordsSendDuration` as a known flake that "passed solo on rerun". The root cause is the `MeterListener` subscribes by `instrument.Meter.Name == GatewayMetrics.MeterName` (a *process-shared* constant `"MxGateway.Server"`), not by the specific `GatewayMetrics` instance constructed in the test. Tests-012 made the xUnit parallelism policy explicit (`parallelizeTestCollections: true`, `maxParallelThreads: -1`), and every other test that builds its own `GatewayMetrics()` and exercises `MxAccessGatewayService.StreamEvents` or `EventStreamService.StreamEventsAsync` (e.g. the new `StreamEventsAsync_*` family added by Tests-026 and Server-041, plus the pre-existing `StreamEventsAsync_YieldsEventsInWorkerOrder` etc.) routes through `GatewayMetrics.RecordEventStreamSend` → the same histogram name `mxgateway.events.stream_send.duration`. When two such tests run concurrently in the same xUnit process, the `MeterListener` in this test sees measurements from *both* meters and `families.Count` grows to >1, breaking `Assert.Equal([MxEventFamily.OnDataChange.ToString()], families)`. Solo reruns pass because no other producer is alive. This is exactly the cross-test mutable-state pattern Tests-012 set the guardrail comment against.
|
||||
|
||||
**Recommendation:** Either (a) filter the `MeterListener` callback by the specific `Meter` instance — capture `metrics._meter` (or expose `GatewayMetrics.Meter`) and compare with `ReferenceEquals(instrument.Meter, expectedMeter)` instead of comparing `Meter.Name`; or (b) place this test in a single-threaded `[Collection("GatewayMetrics-Listener")]` so no other `RecordEventStreamSend` producer runs concurrently. Option (a) is preferred because it removes the cross-talk vector permanently and lets the test stay parallelisable.
|
||||
|
||||
**Resolution:** 2026-05-24 — Applied option (a). Added an `internal Meter Meter => _meter;` accessor on `GatewayMetrics` (visible to the Tests project via the existing `InternalsVisibleTo`) and changed both the `InstrumentPublished` filter and the `SetMeasurementEventCallback<double>` filter in `StreamEvents_WhenEventIsWritten_RecordsSendDuration` from `instrument.Meter.Name == GatewayMetrics.MeterName` to `ReferenceEquals(instrument.Meter, metrics.Meter)`. Added a companion regression `StreamEvents_RecordSendDurationListener_IgnoresMeasurementsFromOtherMetersWithSameName` that constructs a second `GatewayMetrics`, records an `OnWriteComplete` measurement on it before the test-under-test publishes, and asserts the listener captures only the test-under-test's `OnDataChange` family. Confirmed the regression catches the original `Meter.Name`-only filter (got `["OnWriteComplete", "OnDataChange"]` for `["OnDataChange"]`) by temporarily reverting the filter shape; restored ReferenceEquals after. Suite green 3/3 (512/512); the two Tests-027 tests pass 5/5 solo. The cross-talk vector is permanently closed without giving up parallelism.
|
||||
|
||||
### Tests-028
|
||||
|
||||
| Field | Value |
|
||||
@@ -501,12 +503,14 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:466-496,802-807`, `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:216-253` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The new `KillWorkerAsync_KillsWorkerAndRemovesSession` (line 466) and `KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound` (line 486) pin the new kill-path entry, but they do not pin the `reason` argument propagating through the chain. `SessionManager.KillWorkerAsync(sessionId, reason, ct)` validates `reason` with `ArgumentException.ThrowIfNullOrWhiteSpace(reason)` (line 221), calls `session.KillWorker(reason)` (line 229), and logs `reason={Reason}` (line 251); but the `FakeWorkerClient.Kill(string reason)` discards the argument (line 803-807) and the assertion is only `Assert.Equal(1, workerClient.KillCount)`. A regression that (a) hard-coded an internal `"unspecified"` reason between `SessionManager` and `GatewaySession`, (b) swapped to a different overload that dropped the reason, or (c) deleted the `ThrowIfNullOrWhiteSpace` guard would all pass the current tests. The dashboard caller (`DashboardSessionAdminService.KillWorkerAsync`) passes a hard-coded `"dashboard-admin-kill"` reason and the only test that observes it (`KillWorkerAsync_AdminKillsWorker`) asserts `!string.IsNullOrWhiteSpace(LastKillReason)` rather than pinning the value — so the same-class drift is also untested.
|
||||
|
||||
**Recommendation:** (1) Capture `LastKillReason` on `FakeWorkerClient.Kill` and assert `KillWorkerAsync_KillsWorkerAndRemovesSession` propagates the test-supplied `"test-kill"` string end-to-end. (2) Add `KillWorkerAsync_WithBlankReason_ThrowsArgumentException` (parameterised over `null`, `""`, `" "`) to pin the `ArgumentException.ThrowIfNullOrWhiteSpace` guard. (3) Tighten `DashboardSessionAdminServiceTests.KillWorkerAsync_AdminKillsWorker` to `Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason)` so a future reason-string change is a deliberate test update.
|
||||
|
||||
**Resolution:** 2026-05-24 — Added `LastKillReason` to `FakeWorkerClient` in `SessionManagerTests.cs` and set it inside `Kill(string reason)`. Tightened `KillWorkerAsync_KillsWorkerAndRemovesSession` to assert `workerClient.LastKillReason == "test-kill"`, pinning the end-to-end propagation from `SessionManager.KillWorkerAsync` → `session.KillWorker(reason)` → `IWorkerClient.Kill(reason)`. Added `KillWorkerAsync_WithBlankReason_ThrowsArgumentException` as a `[Theory]` over `""`, `" "`, `"\t"` plus a separate `KillWorkerAsync_WithNullReason_ThrowsArgumentNullException` fact (xUnit `InlineData` cannot carry `null` for a non-nullable string, and `ArgumentException.ThrowIfNullOrWhiteSpace` throws `ArgumentNullException` for `null`). Both new tests confirm `KillCount == 0` and the session remains registered, proving the guard fires before any lookup or worker call. Tightened `DashboardSessionAdminServiceTests.KillWorkerAsync_AdminKillsWorker` to `Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason)`. All affected tests pass; suite green.
|
||||
|
||||
### Tests-029
|
||||
|
||||
| Field | Value |
|
||||
@@ -514,12 +518,16 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
||||
| Severity | Low |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs:61-106,139-222`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs:77-125` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The new `DashboardSessionAdminServiceTests` covers the happy path and the viewer-denial path for both `CloseSessionAsync` and `KillWorkerAsync`, plus `CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError` for the close-side `SessionNotFound` catch — but the kill-side error branches are not tested. The product code's `KillWorkerAsync` (lines 111-114) has the same `SessionNotFound` catch returning `"Session {id} was not found."` and (lines 115-124) a generic `SessionManagerException` catch returning `"Kill failed: {message}"`; neither is exercised. The fake's `KillWorkerAsync` (lines 200-209) only succeeds — there is no `KillThrowsNotFound` / `KillThrowsGeneric` configuration option matching the existing `CloseThrowsNotFound`. Symmetrically, `CloseSessionAsync` has the same `IsNullOrWhiteSpace(sessionId)` guard (line 37-40) but no blank-id test even though `KillWorkerAsync_BlankSessionId_ReturnsFailure` exists for the parallel kill guard — a guard-removal regression on close would slip through.
|
||||
|
||||
**Recommendation:** Mirror the existing close-side fixtures onto the kill side: add `KillThrowsNotFound` / `KillThrowsGeneric` init-flags to the `FakeSessionManager`, then `KillWorkerAsync_WhenSessionMissing_ReportsFriendlyError`, `KillWorkerAsync_WhenSessionManagerThrows_ReportsKillFailedMessage`, and `CloseSessionAsync_BlankSessionId_ReturnsFailure`. These are mechanical copies of the existing patterns and bring close/kill coverage into symmetry.
|
||||
|
||||
**Re-triage note:** The Server batch already added `CloseSessionAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail` and `KillWorkerAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail` (the Server-050 regressions visible at HEAD lines 125-162 of the test file), so the kill-side `SessionManagerException` general-catch branch and the close-side parallel are both covered there in a generic-exception shape. The only remaining asymmetry was the blank-session-id guard, per the prompt scope.
|
||||
|
||||
**Resolution:** 2026-05-24 — Added `CloseSessionAsync_BlankSessionId_ReturnsFailure` to `DashboardSessionAdminServiceTests`. The new test invokes `service.CloseSessionAsync(adminUser, " ", ct)` and asserts `Succeeded == false` and `sessionManager.CloseCount == 0`, pinning the `string.IsNullOrWhiteSpace(sessionId)` guard at `DashboardSessionAdminService.cs:52-55`. This brings close/kill blank-id coverage into symmetry with the existing `KillWorkerAsync_BlankSessionId_ReturnsFailure`. The `KillThrowsNotFound` / `KillThrowsGeneric` extensions from the original recommendation are not needed because the unexpected-throw branches are already covered by the Server-050 regressions noted above. All tests pass; suite green.
|
||||
|
||||
### Tests-030
|
||||
|
||||
| Field | Value |
|
||||
@@ -527,12 +535,14 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
||||
| Severity | Low |
|
||||
| Category | Testing coverage |
|
||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs:115-163`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:146-177` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** The three new `DeleteAsync_*` fixtures cover unauthorised user, success path with audit, and store-refuses-with-friendly-error. They do not exercise two production behaviours: (1) `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` (line 151-163) does not construct or inject a `FakeApiKeyAuditStore`, so it never observes that the product code still emits an audit entry with `EventType = "dashboard-delete-key"` and `Details = "not-found-or-active"` on the failure branch (`AppendAuditAsync` runs unconditionally at line 167-172). A regression that placed the `AppendAuditAsync` call inside the `if (deleted)` branch would silently drop the audit trail for refused deletes — a real audit-completeness gap. (2) There is no `DeleteAsync_BlankKeyId_ReturnsFailure` or `DeleteAsync_InvalidKeyId_ReturnsFailure` test, even though `ValidateKeyId(keyId)` (line 156-160) guards on the same conditions as Create/Revoke/Rotate. The `Revoke`/`Rotate` paths have equivalent fixtures (the file's earlier tests cover them); only Delete is missing them.
|
||||
|
||||
**Recommendation:** (1) Add a `FakeApiKeyAuditStore` to `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` and assert it contains exactly one entry with `EventType == "dashboard-delete-key"` and `Details == "not-found-or-active"`. (2) Add `DeleteAsync_BlankKeyId_ReturnsFailure` (parameterised over `null`, `""`, `" "`) and `DeleteAsync_InvalidKeyId_ReturnsFailure` (a keyId with characters the `ValidateKeyId` rules reject) to pin the validation branch end-to-end.
|
||||
|
||||
**Resolution:** 2026-05-24 — Renamed `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` to `DeleteAsync_WhenStoreRefuses_ReportsFriendlyErrorAndAudits` and extended it to inject a `FakeApiKeyAuditStore`; the test now asserts the single audit entry has `EventType == "dashboard-delete-key"`, `KeyId == "operator01"`, and `Details == "not-found-or-active"`. This pins the unconditional-audit invariant at `DashboardApiKeyManagementService.cs:167-172` — a regression moving the `AppendAuditAsync` call inside `if (deleted)` would now fail the test. Added `DeleteAsync_BlankKeyId_ReturnsFailure` as a `[Theory]` over `""`, `" "`, `"\t"` that asserts `Succeeded == false`, `adminStore.DeleteCount == 0`, AND `auditStore.Entries` is empty — pinning that the `ValidateKeyId` guard at line 156-160 fires before any store or audit work. All tests pass; suite green.
|
||||
|
||||
### Tests-031
|
||||
|
||||
| Field | Value |
|
||||
@@ -540,8 +550,10 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
||||
| Severity | Low |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs:22-61` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `ExecuteAsync_WhenSnapshotServiceThrowsOnce_ReconnectsAfterDelay` records `startedAt = DateTimeOffset.UtcNow` *before* calling `publisher.StartAsync(...)`, then asserts `secondSubscribeAt - startedAt >= reconnectDelay - 10ms` (line 59). The measured gap is *not* the reconnect delay in isolation — it is `(StartAsync scheduling) + (first WatchSnapshotsAsync setup + Task.Yield) + (throw) + reconnect delay + (second WatchSnapshotsAsync setup)`. On a slow/contended CI agent the first three terms easily dominate (favouring the assertion); but on a fast machine, Windows `Task.Delay(50ms)` rounds up to the next ~15.6 ms tick boundary and may return at ~46-50 ms relative to schedule, while the first three terms can be sub-millisecond — so the gap measurement can land within 1-2 ms of the lower bound, and the 10 ms slack may not absorb a single missed quantum. This is a latent flake of the same flavour as Tests-006 (heartbeat timing) but on a wall-clock dependency the test cannot inject around because `DashboardSnapshotPublisher` uses `Task.Delay(_reconnectDelay)` directly. Tests-006 / Tests-017 moved heartbeat tests onto `ManualTimeProvider`; this test cannot do that without a product change to use a `TimeProvider`-aware delay.
|
||||
|
||||
**Recommendation:** (a) The cheap fix: have `ThrowOnceThenYieldSnapshotService` record `_firstThrowAt = DateTimeOffset.UtcNow` immediately before the `throw`, and change the assertion to `secondSubscribeAt - firstThrowAt >= reconnectDelay - 10ms` — the gap then measures only the reconnect delay, eliminating the variable scheduling baseline. (b) The deeper fix: extend `DashboardSnapshotPublisher` to accept an `ITimeProvider`-style delay seam (or a virtual `DelayAsync` hook) so a `ManualTimeProvider` could advance time deterministically. (a) is preferred for now; (b) belongs as a follow-up if more reconnect-loop tests are added.
|
||||
|
||||
**Resolution:** 2026-05-24 — Applied option (a). Added `FirstThrowAt` to `ThrowOnceThenYieldSnapshotService` and set it via `FirstThrowAt = DateTimeOffset.UtcNow;` immediately before the first-call `throw`. Removed the pre-`StartAsync` `startedAt` baseline; the assertion now reads `gap = secondSubscribeAt - firstThrowAt` (both timestamps captured inside the fake), and the 10 ms slack absorbs the Windows `Task.Delay` quantum without the variable `StartAsync` / scheduling overhead in the baseline. This is the same flake-isolation pattern Tests-006 / Tests-017 used (measuring only the production delay, not test-side setup). Suite green; the test passes deterministically across repeated runs.
|
||||
|
||||
@@ -51,6 +51,19 @@ The shared inputs are:
|
||||
The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's
|
||||
`api-key-env` flag. They must not embed bearer tokens or raw API keys.
|
||||
|
||||
### TLS variant
|
||||
|
||||
The matrix runs over plaintext (`h2c`) by default. A TLS variant exists but stays
|
||||
a manual/opt-in run, consistent with the gate above, because it needs the gateway
|
||||
started with an HTTPS endpoint (an `https://` `MXGATEWAY_ENDPOINT`) and each CLI
|
||||
switched to its TLS flag (`--tls` / `-tls` / `--plaintext=false` /
|
||||
`plaintext=False`). The clients are lenient by default and accept the gateway's
|
||||
auto-generated self-signed certificate without extra trust setup, except the Rust
|
||||
CLI, which is pin-only and needs `--ca-file` or `--require-certificate-validation`
|
||||
(and Python uses trust-on-first-use). See
|
||||
[Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||
and each client README for the per-client TLS flags.
|
||||
|
||||
## JSON Comparison
|
||||
|
||||
Every command in the matrix requests JSON output. A runner can compare the
|
||||
|
||||
@@ -362,6 +362,55 @@ Dashboard access should require API-key-backed dashboard authentication with
|
||||
is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is
|
||||
limited to loopback requests.
|
||||
|
||||
## Lazy Browse Is Wire-Only
|
||||
|
||||
Decision: the gateway continues to pull the full Galaxy hierarchy on each
|
||||
deploy. `BrowseChildren` and the lazy dashboard render only avoid sending and
|
||||
DOM-materializing the full tree — they do not push laziness into SQL or cache
|
||||
loading.
|
||||
|
||||
Rationale: snapshot persistence and the dashboard summary both depend on a
|
||||
fully-materialized cache. Lazy SQL would increase per-click latency on a
|
||||
deployment-heavy box, multiply per-session SQL connections, and complicate the
|
||||
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
|
||||
replies and a heavy DOM) without disturbing the materialization model.
|
||||
|
||||
## TLS Auto-Certificate and Lenient Client Trust
|
||||
|
||||
Decision: when a Kestrel `https://` endpoint is configured without a certificate
|
||||
of its own (and no `Kestrel:Certificates:Default` is set), the gateway generates
|
||||
and persists a self-signed certificate rather than failing to start. Clients
|
||||
connecting over TLS without a pinned CA accept whatever certificate the server
|
||||
presents by default; pinning a CA restores full verification.
|
||||
|
||||
Rationale: `mxaccessgw` is an internal tool with no PKI to issue or distribute
|
||||
certificates. The prior behavior — an `https` endpoint with no certificate
|
||||
fails at startup with Kestrel's opaque "no server certificate was specified"
|
||||
error — pushed operators toward plaintext (`h2c`), exposing the API key and
|
||||
request payloads on the wire. Auto-generating a long-lived, persisted, reused
|
||||
certificate lets TLS "just work" with zero certificate management, while the
|
||||
lenient client default means clients connect to that self-signed certificate
|
||||
without a manual trust step. Both choices are deliberate, not oversights:
|
||||
strict-by-default would force PKI work this tool does not warrant. Plaintext-only
|
||||
deployments are untouched — no certificate or key material is written for them —
|
||||
and an operator who supplies a real certificate transparently overrides the
|
||||
generated one.
|
||||
|
||||
Two clients diverge from "accept any certificate" because their gRPC stacks lack
|
||||
a per-channel skip-verify hook:
|
||||
|
||||
- Python uses trust-on-first-use: it fetches the server's presented certificate
|
||||
over a separate unverified probe and pins it for the channel, and defaults the
|
||||
SNI/target-name override to `localhost` (the generated certificate always
|
||||
carries a `localhost` SAN).
|
||||
- Rust is pin-only: tonic exposes no public hook to inject a custom certificate
|
||||
verifier, so TLS over Rust requires either a pinned CA or an explicit opt-in to
|
||||
system-trust verification; otherwise connecting returns a clear, actionable
|
||||
error.
|
||||
|
||||
See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||
and the per-client READMEs for the as-built behavior.
|
||||
|
||||
## Later Revisit Items
|
||||
|
||||
These are explicit post-v1 revisit items, not open blockers:
|
||||
|
||||
@@ -36,6 +36,7 @@ The service is defined in
|
||||
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
|
||||
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
|
||||
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
|
||||
| `BrowseChildren` | Returns the direct children of one parent object (or root objects when `parent` is unset). Filters mirror `DiscoverHierarchy`. Includes a per-child `has_children` hint so UIs can draw expand triangles without an extra round trip. **Served from cache.** |
|
||||
|
||||
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
|
||||
and `page_token`; the server defaults omitted page size to 1000 objects and
|
||||
@@ -52,6 +53,57 @@ alarm-only, historized-only, and `include_attributes = false` for a skeleton
|
||||
tree. All filters are applied with AND semantics, and `total_object_count`
|
||||
reports the post-filter count.
|
||||
|
||||
### BrowseChildren
|
||||
|
||||
`BrowseChildren` is an OPC UA-style lazy expand: clients that walk one level at
|
||||
a time — UI trees, OPC UA address-space bridges — call it instead of paging the
|
||||
full hierarchy with `DiscoverHierarchy`.
|
||||
|
||||
**Parent selection.** The `parent` oneof accepts `parent_gobject_id`,
|
||||
`parent_tag_name`, or `parent_contained_path`. An empty oneof returns root
|
||||
objects — those whose `parent_gobject_id` is 0.
|
||||
|
||||
**Filters.** Category ids, template-chain substring, tag-name glob, alarm-only,
|
||||
historized-only, and `include_attributes` all behave identically to
|
||||
`DiscoverHierarchy` and are AND-combined. One important difference applies to
|
||||
`alarm_bearing_only` and `historized_only`: an ancestor that does not itself
|
||||
carry a matching attribute is still returned when one of its descendants does.
|
||||
This is intentional — without it a UI tree cannot navigate to the matching
|
||||
leaves. `DiscoverHierarchy`'s flat-list semantics filter out such intermediate
|
||||
ancestors; `BrowseChildren` retains them so the path to each match remains
|
||||
traversable.
|
||||
|
||||
**`child_has_children` hint.** The reply carries a boolean parallel to
|
||||
`children`, set true when the child has at least one matching descendant under
|
||||
the same filter set. UIs can use this to decide whether to draw an expand
|
||||
triangle without issuing a follow-up `BrowseChildren` call. Because the hint is
|
||||
computed against the *filtered* descendant set, a branch that contains no
|
||||
matching objects gets `false`, not `true`.
|
||||
|
||||
**Paging.** Default page size is 500; the server caps any requested size at
|
||||
5000. Page tokens encode `(cache_sequence, parent_id, filter_signature,
|
||||
offset)`. A token from a different cache generation or a different filter set
|
||||
returns `InvalidArgument`. The error messages reference "DiscoverHierarchy
|
||||
page_token" because `BrowseChildren` reuses the same encoding and validation
|
||||
path — if you see that wording in a `BrowseChildren` context it is expected.
|
||||
|
||||
**Errors.**
|
||||
|
||||
| Condition | Status code |
|
||||
|-----------|-------------|
|
||||
| Unknown parent | `NotFound` |
|
||||
| First load not yet complete after 5 s | `Unavailable` |
|
||||
| Stale or filter-mismatched page token | `InvalidArgument` |
|
||||
| Missing `metadata:read` scope | `PermissionDenied` |
|
||||
| No API key | `Unauthenticated` |
|
||||
|
||||
**Authorization.** Same `metadata:read` scope as the other Galaxy RPCs.
|
||||
`browse_subtrees` API-key constraints intersect with the result set.
|
||||
|
||||
**Sort order.** Areas first, then `OrdinalIgnoreCase` by display name
|
||||
(`browse_name` → `contained_name` → `tag_name`). Matches the dashboard tree so
|
||||
server and dashboard views are consistent.
|
||||
|
||||
## Hierarchy Cache
|
||||
|
||||
The gateway holds a single shared `IGalaxyHierarchyCache`
|
||||
@@ -271,9 +323,13 @@ fields cannot express null. Use it to distinguish "no dimension reported" from
|
||||
```text
|
||||
gRPC client(s)
|
||||
-> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/)
|
||||
DiscoverHierarchy, GetLastDeployTime -> IGalaxyHierarchyCache.Current
|
||||
WatchDeployEvents -> IGalaxyDeployNotifier
|
||||
TestConnection -> GalaxyRepository (direct SQL)
|
||||
DiscoverHierarchy, GetLastDeployTime, BrowseChildren -> IGalaxyHierarchyCache.Current
|
||||
WatchDeployEvents -> IGalaxyDeployNotifier
|
||||
TestConnection -> GalaxyRepository (direct SQL)
|
||||
|
||||
Dashboard (Blazor)
|
||||
-> IDashboardBrowseService (DashboardBrowseService)
|
||||
-> GalaxyBrowseProjector over IGalaxyHierarchyCache.Current
|
||||
|
||||
GalaxyHierarchyRefreshService (BackgroundService)
|
||||
-> IGalaxyHierarchyCache.RefreshAsync
|
||||
@@ -309,9 +365,17 @@ Component breakdown:
|
||||
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to
|
||||
proto messages. Used by the cache during refresh to materialize the reply
|
||||
once.
|
||||
- `GalaxyBrowseProjector`
|
||||
(`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs`) projects one level
|
||||
of children out of an immutable cache entry. Memoizes the filtered child list
|
||||
per cache-entry instance so repeated paging is an O(pageSize) slice rather than an
|
||||
O(siblings) filter scan. The memo is keyed on the cache entry reference, so a new
|
||||
entry from the background refresh makes the stale memo unreachable and it is
|
||||
collected with it. `DashboardBrowseService` wraps this projector to drive the
|
||||
dashboard's lazy-expand tree.
|
||||
- `GalaxyRepositoryGrpcService`
|
||||
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements
|
||||
the four RPCs.
|
||||
the five RPCs.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
|
||||
"Dashboard": {
|
||||
"Enabled": true,
|
||||
"AllowAnonymousLocalhost": true,
|
||||
"RequireHttpsCookie": true,
|
||||
"SnapshotIntervalMilliseconds": 1000,
|
||||
"RecentFaultLimit": 100,
|
||||
"RecentSessionLimit": 200,
|
||||
@@ -146,6 +147,7 @@ the affected stream while the MXAccess session remains active.
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||
@@ -227,6 +229,185 @@ behavior.
|
||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||
|
||||
## Host Endpoints and Transport Security (Kestrel)
|
||||
|
||||
The listening endpoints are **not** part of the `MxGateway` section. The gateway
|
||||
uses the stock ASP.NET Core host (`WebApplication.CreateBuilder`) with no
|
||||
`ConfigureKestrel` call in code, so endpoints come entirely from the standard
|
||||
`Kestrel` configuration section. On the deployed hosts these values are supplied
|
||||
as NSSM environment variables (`Kestrel__Endpoints__...`), not from
|
||||
`appsettings.json`.
|
||||
|
||||
Two named endpoints are bound:
|
||||
|
||||
| Endpoint name | Purpose | Protocol requirement |
|
||||
|---|---|---|
|
||||
| `Http` | Public gRPC API (sessions, invoke, events, Galaxy browse) | HTTP/2 |
|
||||
| `Dashboard` | Blazor dashboard and SignalR hubs | HTTP/1.1 (HTTP/2 optional) |
|
||||
|
||||
Both endpoints share one routing pipeline; the names only select which TCP port
|
||||
serves which traffic. The gRPC endpoint must negotiate **HTTP/2**, which drives
|
||||
the protocol settings below.
|
||||
|
||||
### Plaintext (current deployments)
|
||||
|
||||
Both running hosts (`10.100.0.48` and `wonder-app-vd03`) serve the gRPC port in
|
||||
**cleartext HTTP/2 (`h2c`)**. Because cleartext HTTP/2 has no ALPN to negotiate
|
||||
the protocol, the gRPC endpoint must be pinned to `Http2` with prior knowledge:
|
||||
|
||||
```text
|
||||
Kestrel__Endpoints__Http__Url=http://0.0.0.0:5120
|
||||
Kestrel__Endpoints__Http__Protocols=Http2
|
||||
Kestrel__Endpoints__Dashboard__Url=http://0.0.0.0:5130
|
||||
```
|
||||
|
||||
In this mode all client↔gateway traffic — including the
|
||||
`authorization: Bearer mxgw_...` API key and any `WriteSecured` / `AuthenticateUser`
|
||||
payloads — crosses the network **unencrypted**. This is acceptable only on a
|
||||
trusted/isolated network segment. Prefer TLS for anything else.
|
||||
|
||||
### TLS
|
||||
|
||||
To encrypt the gRPC channel, give the `Http` endpoint an `https://` URL and a
|
||||
certificate. Over TLS, ALPN negotiates HTTP/2, so the explicit `Protocols=Http2`
|
||||
pin is no longer required (the default `Http1AndHttp2` works for gRPC over TLS).
|
||||
|
||||
`appsettings.json` form:
|
||||
|
||||
```json
|
||||
{
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "https://0.0.0.0:5120",
|
||||
"Certificate": {
|
||||
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
|
||||
"Password": "<pfx-password>"
|
||||
}
|
||||
},
|
||||
"Dashboard": {
|
||||
"Url": "https://0.0.0.0:5130",
|
||||
"Certificate": {
|
||||
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
|
||||
"Password": "<pfx-password>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent NSSM environment-variable form (how config is delivered on the hosts —
|
||||
see [server deploy mechanics in the project notes]):
|
||||
|
||||
```text
|
||||
Kestrel__Endpoints__Http__Url=https://0.0.0.0:5120
|
||||
Kestrel__Endpoints__Http__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
|
||||
Kestrel__Endpoints__Http__Certificate__Password=<pfx-password>
|
||||
Kestrel__Endpoints__Dashboard__Url=https://0.0.0.0:5130
|
||||
Kestrel__Endpoints__Dashboard__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
|
||||
Kestrel__Endpoints__Dashboard__Certificate__Password=<pfx-password>
|
||||
```
|
||||
|
||||
Certificate sourcing options (any standard ASP.NET Core form is accepted):
|
||||
|
||||
| Form | Keys |
|
||||
|---|---|
|
||||
| PFX file | `Certificate:Path` (+ `Certificate:Password` if encrypted) |
|
||||
| PEM pair | `Certificate:Path` (cert) + `Certificate:KeyPath` (private key) |
|
||||
| Windows cert store | `Certificate:Subject`, `Certificate:Store` (e.g. `My`), `Certificate:Location` (`LocalMachine`), `Certificate:AllowInvalid` |
|
||||
|
||||
The certificate's CN/SAN must cover the host name clients dial (or clients must
|
||||
set a server-name override — see below). The dashboard endpoint can keep its own
|
||||
certificate independent of the gRPC endpoint; pair this with
|
||||
`MxGateway:Dashboard:RequireHttpsCookie` (`true`) for production HTTPS.
|
||||
|
||||
### Automatic self-signed certificate
|
||||
|
||||
`mxaccessgw` is an internal tool with no PKI to issue certificates, so requiring
|
||||
an operator to supply one before TLS works pushed deployments toward plaintext.
|
||||
To avoid that, the gateway fills in a self-signed certificate when an HTTPS
|
||||
endpoint is configured without one.
|
||||
|
||||
**Trigger.** At startup the gateway inspects `Kestrel:Endpoints:*`. If any
|
||||
endpoint has an `https://` URL and no `Certificate` subsection of its own, and no
|
||||
`Kestrel:Certificates:Default` is set, the gateway generates (or loads) a
|
||||
persisted self-signed certificate and wires it in as the HTTPS *default* via
|
||||
`ConfigureHttpsDefaults`. All-plaintext deployments are untouched: when no HTTPS
|
||||
endpoint is configured, no certificate or key material is generated or written.
|
||||
|
||||
**Generated certificate.** ECDSA P-256, `serverAuth` EKU, validity ≈
|
||||
`ValidityYears` (default 10 years, with one day of clock-skew slack before
|
||||
`notBefore`). SANs cover `localhost`, the machine name (and its FQDN when
|
||||
resolvable), each entry in `AdditionalDnsNames`, and the loopback addresses
|
||||
`127.0.0.1` and `::1`.
|
||||
|
||||
**`MxGateway:Tls:*` options.** All optional; the zero-config path needs none of
|
||||
them.
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted |
|
||||
| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) |
|
||||
| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) |
|
||||
| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing |
|
||||
|
||||
`ValidityYears` is validated by `GatewayOptionsValidator` (range 1–100); the
|
||||
"HTTPS endpoint configured but no certificate available" fail-fast lives in the
|
||||
bootstrap/provider, because the validator only sees the `MxGateway` section, not
|
||||
`Kestrel:Endpoints`.
|
||||
|
||||
**Persistence.** The PFX is written with an **empty** export password — a random
|
||||
in-memory password could not be reused across restarts, which the
|
||||
persist-and-reuse model requires. The private key is instead protected at rest by
|
||||
filesystem permissions: a restrictive ACL on Windows (SYSTEM + Administrators,
|
||||
inherited ACEs stripped) on the `certs` directory and file, and mode `0600` on
|
||||
non-Windows. The write is atomic (hardened temp file, then move). The persisted
|
||||
certificate is reused across restarts (stable thumbprint, so CA-pinning clients
|
||||
keep working) and regenerated only when it is missing, expired (and
|
||||
`RegenerateIfExpired` is `true`), or unreadable/corrupt. If the directory is not
|
||||
writable or the ACL cannot be applied, the gateway fails fast with a diagnostic
|
||||
naming the path rather than falling back to an in-memory certificate.
|
||||
|
||||
**Logging.** On generate or load, the gateway logs the certificate thumbprint,
|
||||
SAN list, and `notAfter` at Information. The PFX bytes, export password, and
|
||||
private key are never logged.
|
||||
|
||||
**Operator override.** The generated certificate is only the HTTPS *default*. To
|
||||
use a real certificate, configure one explicitly — either per endpoint via
|
||||
`Kestrel:Endpoints:<name>:Certificate` (`Path`/`Subject`/`Thumbprint`, etc., as
|
||||
in the table above) or globally via `Kestrel:Certificates:Default`. An
|
||||
explicitly-configured certificate takes precedence, and the gateway then writes
|
||||
no self-signed material.
|
||||
|
||||
### Client side
|
||||
|
||||
Each official client opts into TLS explicitly. For the .NET client
|
||||
(`MxGatewayClientOptions`):
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `UseTls` (default `false`) | Enables TLS. Requires an `https://` endpoint; an `https://` endpoint without `UseTls` fails validation, and vice versa. |
|
||||
| `CaCertificatePath` | Pins a custom root (self-signed / private CA) using `CustomRootTrust` chain validation instead of the OS trust store; the .NET client also enforces the certificate hostname/SAN match on this path. |
|
||||
| `RequireCertificateValidation` (default `false`) | Forces OS/system-trust verification on a TLS connection with no pinned CA. Leave `false` for the lenient default. |
|
||||
| `ServerNameOverride` | SNI / certificate host name override when the dialed host differs from the certificate CN/SAN. |
|
||||
|
||||
To pair with the auto-generated self-signed certificate above, the clients are
|
||||
**lenient by default**: a TLS connection with no pinned CA accepts whatever
|
||||
certificate the gateway presents. Pin `CaCertificatePath` to verify, or set
|
||||
`RequireCertificateValidation` to force system-trust verification without
|
||||
pinning. The other language clients expose the equivalent options; the exact
|
||||
behavior differs per stack — Python uses trust-on-first-use and Rust is pin-only.
|
||||
See each client README for the as-built behavior.
|
||||
|
||||
### Gateway↔worker IPC
|
||||
|
||||
Transport security here applies only to the public gRPC channel. The
|
||||
gateway↔worker link is a per-session **named pipe**
|
||||
(`mxaccess-gateway-{gatewayPid}-{sessionId}`), not a network socket. It is not
|
||||
TLS-encrypted and does not need to be: it never leaves the local Windows host and
|
||||
is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||
|
||||
@@ -243,9 +243,27 @@ services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInt
|
||||
|
||||
Because the interceptor runs before any handler, `MxAccessGatewayService` can safely assume the call has been authorized and that `IGatewayRequestIdentityAccessor.Current` is populated. The handler's only responsibility is to read the identity for `OpenSession` so the session is owned by the authenticated principal; it does not perform any authorization checks of its own. See [Authorization](./Authorization.md) for the policy and identity model.
|
||||
|
||||
## Transport Security
|
||||
|
||||
The gRPC endpoint runs over HTTP/2, in cleartext (`h2c`) or TLS depending on the
|
||||
Kestrel endpoint configuration. The current deployments serve it in cleartext, so
|
||||
the API key and request payloads cross the network unencrypted. The endpoint,
|
||||
protocol pinning, and TLS certificate configuration — plus the corresponding
|
||||
client `UseTls` / `CaCertificatePath` options — are documented in
|
||||
[Host Endpoints and Transport Security](./GatewayConfiguration.md#host-endpoints-and-transport-security-kestrel).
|
||||
|
||||
To make TLS usable without PKI, the gateway can auto-generate and persist a
|
||||
self-signed certificate when an HTTPS endpoint is configured without one, and the
|
||||
language clients are lenient by default — a TLS connection with no pinned CA
|
||||
accepts the presented certificate (with per-stack nuances: Python is
|
||||
trust-on-first-use, Rust is pin-only). See
|
||||
[Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||
and each client README for the as-built behavior.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Contracts](./Contracts.md)
|
||||
- [Sessions](./Sessions.md)
|
||||
- [Authorization](./Authorization.md)
|
||||
- [Gateway Configuration](./GatewayConfiguration.md)
|
||||
- [Gateway Process Design](./GatewayProcessDesign.md)
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d
|
||||
|
||||
## Overview
|
||||
|
||||
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
||||
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
||||
|
||||
## Meter and OpenTelemetry Compatibility
|
||||
|
||||
@@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit
|
||||
```csharp
|
||||
public sealed class GatewayMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "ZB.MOM.WW.MxGateway.Server";
|
||||
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||
|
||||
public GatewayMetrics()
|
||||
{
|
||||
@@ -50,12 +50,12 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
|
||||
|
||||
### Histograms
|
||||
|
||||
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`):
|
||||
Histograms record durations in seconds (the `unit` argument on `CreateHistogram`):
|
||||
|
||||
```csharp
|
||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||
```
|
||||
|
||||
| Instrument | Tags | What it measures |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user