bde042b4d4
Every parity-critical single-item MXAccess command now has a typed session helper instead of only a raw-Invoke escape hatch. Added per client: - Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser, ArchestrAUserToId - Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate - CLI-30: Unregister (Rust + .NET; Go/Python already had it) Each wraps the existing raw-command machinery (no new wire surface) and runs the same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces the native failure unchanged (not pre-validated/reordered). Credentials (AuthenticateUser password, WriteSecured payloads) route through each client's secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors. New CLI subcommands source credentials via flag/env, never echoed. - .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor. Build clean (0 warn), 102 passed. - Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory to typed. gofmt/vet/build/test clean. - Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on secured paths; error.rs credential scrub. fmt/check/test/clippy clean. - Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands. 145 passed. - Shared doc: ClientLibrariesDesign "Typed Command Parity" section. Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30 stay open until it lands. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
423 lines
18 KiB
Markdown
423 lines
18 KiB
Markdown
# Rust Client Workspace
|
|
|
|
The Rust client workspace contains the MXAccess Gateway client library, a
|
|
test CLI, and tests for generated contract wiring plus wrapper behavior. The
|
|
library uses
|
|
the shared protobuf inputs documented in
|
|
`../../docs/ClientProtoGeneration.md` so the Rust bindings compile against
|
|
the same public gateway and worker contracts as the server.
|
|
|
|
## Layout
|
|
|
|
```text
|
|
clients/rust/
|
|
Cargo.toml
|
|
build.rs
|
|
src/
|
|
tests/
|
|
crates/mxgw-cli/
|
|
```
|
|
|
|
`build.rs` reads the `.proto` files from
|
|
`../../src/ZB.MOM.WW.MxGateway.Contracts/Protos` and generates `tonic`/`prost` bindings
|
|
into Cargo build output. `src/generated.rs` declares the Rust modules that
|
|
include those generated files. `src/generated` remains reserved for checked-in
|
|
generator output if the crate later changes to source-tree generation.
|
|
|
|
## Build And Test
|
|
|
|
Run the Rust workspace checks from `clients/rust`:
|
|
|
|
```powershell
|
|
cargo fmt --all --check
|
|
cargo test --workspace
|
|
cargo check --workspace
|
|
cargo clippy --workspace --all-targets -- -D warnings
|
|
```
|
|
|
|
The build script uses `protoc` from `PATH` or the Windows path recorded in
|
|
`../../docs/ToolchainLinks.md`.
|
|
|
|
## Packaging
|
|
|
|
Create local release artifacts from `clients/rust`:
|
|
|
|
```powershell
|
|
cargo build --workspace --release
|
|
cargo install --path crates/mxgw-cli --locked --force
|
|
```
|
|
|
|
`cargo check --workspace` regenerates the `tonic` and `prost` modules into
|
|
Cargo build output through `build.rs`.
|
|
|
|
## CLI
|
|
|
|
The CLI exposes version, session, command, event stream, write, and smoke
|
|
commands over the same client wrapper used by tests:
|
|
|
|
```powershell
|
|
cargo run -p mxgw-cli -- version --json
|
|
cargo run -p mxgw-cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
|
cargo run -p mxgw-cli -- register --session-id <session-id> --client-name mxgw-rust-cli --json
|
|
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --json
|
|
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
|
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
|
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 1 --json
|
|
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
|
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
|
```
|
|
|
|
Use `--tls`, `--ca-file`, and `--server-name-override` for TLS endpoints. The
|
|
CLI reads the API key from `--api-key` or from `--api-key-env`, which defaults
|
|
to `MXGATEWAY_API_KEY`. API keys are redacted by the library option and secret
|
|
types.
|
|
|
|
```powershell
|
|
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
|
```
|
|
|
|
### TLS trust (pin-only)
|
|
|
|
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
|
Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1
|
|
exposes no public hook to inject a custom certificate verifier, so TLS over Rust
|
|
cannot accept an *arbitrary* self-signed certificate. A TLS connection requires
|
|
one of two trust paths:
|
|
|
|
- `--ca-file` / `ClientOptions::with_ca_file(...)` to pin a CA (export the
|
|
gateway's self-signed certificate and pin it). This is the path for a
|
|
self-signed gateway.
|
|
- `--require-certificate-validation` / `with_require_certificate_validation(true)`
|
|
to verify against the operating system's trust roots (`tls-native-roots`). This
|
|
only succeeds for a certificate that chains to a root the host already trusts —
|
|
i.e. a gateway fronted by a publicly- or enterprise-CA-issued certificate, not a
|
|
bare self-signed one.
|
|
|
|
TLS with neither set fails `connect` with a clear, actionable error rather
|
|
than accepting the certificate. See
|
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
|
|
|
## Library Surface
|
|
|
|
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
|
timeouts, custom CA files, and server name override. `GatewayClient::connect`
|
|
creates an authenticated `tonic` client and attaches `authorization: Bearer
|
|
<api-key>` metadata to unary and streaming calls.
|
|
|
|
`GatewayClient` exposes raw generated calls through `open_session_raw`,
|
|
`close_session_raw`, `invoke_raw`, `stream_events`, `query_active_alarms`,
|
|
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
|
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
|
shares the gateway's central alarm monitor with every other client. The
|
|
session helpers keep MXAccess handles visible:
|
|
|
|
```rust
|
|
let session = client.open_session(request).await?;
|
|
let server_handle = session.register("mxgw-rust").await?;
|
|
let item_handle = session.add_item(server_handle, "TestChildObject.TestInt").await?;
|
|
session.advise(server_handle, item_handle).await?;
|
|
let mut events = session.events().await?;
|
|
session.close().await?;
|
|
```
|
|
|
|
`MxValue`, `MxArrayValue`, and `MxStatus` wrap generated protobuf messages while
|
|
preserving the raw message for parity diagnostics. Command replies whose
|
|
protocol status is not `PROTOCOL_STATUS_CODE_OK` become `Error::Command` and
|
|
retain the raw `MxCommandReply`.
|
|
|
|
The typed command helpers (`register`, `add_item`, `write`, the bulk variants,
|
|
etc.) also enforce MXAccess parity on an otherwise-OK reply: a reply that
|
|
reports a negative `hresult` (COM failure semantics — a positive code such as
|
|
`S_FALSE = 1` is a success) or a non-success `MXSTATUS_PROXY` status entry
|
|
becomes `Error::MxAccess`, which boxes an `MxAccessError` retaining the raw
|
|
`MxCommandReply` (recover it with `MxAccessError::reply` / `into_reply`). Its
|
|
message summarizes the `hresult` and status entries with credential-safe
|
|
redaction. Per-item bulk failures are reported inside each result entry
|
|
(`was_successful = false`) and do not raise `Error::MxAccess`. The raw
|
|
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
|
|
returns the unvalidated reply.
|
|
|
|
## Event Streaming And Reconnect-Replay Gaps
|
|
|
|
`session.events()` / `session.events_after(after_worker_sequence)` (and the
|
|
lower-level `client.stream_events`) return an `EventStream` that yields
|
|
`EventItem` values, not bare `MxEvent`s:
|
|
|
|
```rust
|
|
use zb_mom_ww_mxgateway_client::EventItem;
|
|
|
|
let mut stream = session.events_after(cursor).await?;
|
|
while let Some(item) = stream.next().await {
|
|
match item? {
|
|
EventItem::Event(event) => { /* apply the MXAccess change */ }
|
|
EventItem::ReplayGap(gap) => {
|
|
// Recent history was evicted — discard local state and re-snapshot,
|
|
// then resume without provoking another gap:
|
|
let resume = gap.oldest_available_sequence.saturating_sub(1);
|
|
stream = session.events_after(resume).await?;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Almost every item is a normal `EventItem::Event`. `EventItem::ReplayGap` is a
|
|
faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the
|
|
client does not synthesize it. The gateway emits the sentinel at most once, at
|
|
the head of a stream **resumed** via `events_after` (`after_worker_sequence`)
|
|
when the requested sequence is older than the oldest event still retained in the
|
|
session's replay ring: events in the open interval
|
|
`(requested_after_sequence, oldest_available_sequence)` were evicted and cannot
|
|
be replayed. A `ReplayGap` therefore means "you missed events — discard any
|
|
local state and re-snapshot." To resume without a second gap, reconnect with
|
|
`events_after(gap.oldest_available_sequence - 1)`, which replays starting at the
|
|
first still-retained event. A stream opened from the beginning
|
|
(`session.events()` / `events_after(0)`) never produces a `ReplayGap`.
|
|
|
|
`EventItem` provides `as_event()`, `into_event()`, and `replay_gap()` accessors
|
|
for callers that prefer not to `match`. The `mxgw-cli stream-events` subcommand
|
|
renders the sentinel as a distinct `REPLAY_GAP …` line (or a `replayGap` JSON
|
|
object under `--json` / `--jsonl`).
|
|
|
|
## Write Semantics And Common Pitfalls
|
|
|
|
These are MXAccess parity behaviors that surprise new callers. The gateway
|
|
forwards them unchanged — it does not paper over them.
|
|
|
|
### Attributing a write to a user without `authenticate_user`
|
|
|
|
MXAccess only stamps a plain `write`/`write2` with a Galaxy user id when the
|
|
item carries an active *supervisory* advise. If you are **not** using the
|
|
verified/secured path (`authenticate_user` → `write_secured`/`write_secured2`)
|
|
but still need the write attributed to a user id, you must first advise the
|
|
item supervisory and then pass that user id on the write. Without the
|
|
supervisory advise the `user_id` on a plain write is ignored.
|
|
|
|
The session exposes a typed `advise_supervisory` helper alongside
|
|
`advise`/`un_advise`:
|
|
|
|
```rust
|
|
session.advise_supervisory(server_handle, item_handle).await?;
|
|
session.write(server_handle, item_handle, value, user_id).await?;
|
|
```
|
|
|
|
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
|
`write2` take `--user-id`.
|
|
|
|
### Verified / secured writes and user resolution
|
|
|
|
The verified path has typed session helpers too: `authenticate_user` (returns
|
|
the resolved MXAccess user id), `archestra_user_to_id`, and
|
|
`write_secured` / `write_secured2`. MXAccess parity is preserved — a
|
|
`write_secured` issued before the required `authenticate_user` +
|
|
`advise_supervisory` (or before a value-bearing body) fails natively and the
|
|
failure surfaces as `Error::MxAccess`; it is not smoothed over. Credentials
|
|
passed to `authenticate_user` (and secured write payloads) are placed only on
|
|
the wire — the client never logs them and never embeds them in an `Error`'s
|
|
`Display`/`Debug`; the only error text that can surface (from `tonic::Status`
|
|
messages and reply diagnostics) is scrubbed by the credential-redaction seam.
|
|
The CLI mirrors these as `authenticate-user` (password via `--password` or the
|
|
`--password-env` env var, never echoed) and `write-secured`.
|
|
|
|
The remaining single-item command helpers round out MXAccess parity:
|
|
`unregister`, `suspend` / `activate` (each returns the operation's
|
|
`MxStatus`), `add_buffered_item`, and `set_buffered_update_interval`.
|
|
|
|
### Array writes replace the whole array
|
|
|
|
A write to an array attribute **replaces the entire array**; it is not an
|
|
element-wise patch. To change a subset of elements, send the full array with
|
|
the unchanged elements included. For example, to change 2 elements of a
|
|
20-element array, build the `MxValue` from all 20 values (the 18 unchanged plus
|
|
the 2 new ones). Sending only the 2 changed values overwrites the attribute
|
|
with a 2-element array.
|
|
|
|
#### Default-fill partial array writes
|
|
|
|
When you only need to set a handful of indices and want every other position to
|
|
take the element type's default (zero / `false` / empty string / Unix epoch for
|
|
timestamps), use `Session::write_array_elements` instead:
|
|
|
|
```rust
|
|
// Write a 10-element integer array; index 0 = 42, index 7 = 99,
|
|
// all other indices default to 0 (not preserved from the previous value).
|
|
session
|
|
.write_array_elements(
|
|
server_handle,
|
|
item_handle,
|
|
MxDataType::Integer,
|
|
10,
|
|
[(0, MxValue::int32(42)), (7, MxValue::int32(99))],
|
|
user_id,
|
|
)
|
|
.await?;
|
|
```
|
|
|
|
The gateway expands the sparse representation into a full `MxArray` before
|
|
forwarding to the worker — the worker and MXAccess COM never see the sparse
|
|
form. Unmentioned indices are reset to the type default, **not** preserved from
|
|
the existing attribute value.
|
|
|
|
#### Bare-name array AddItem normalisation
|
|
|
|
Adding a bare array attribute name (e.g. `Tank01.Temperature`) via `AddItem`,
|
|
`AddItem2`, `AddItemBulk`, or `AddBufferedItem` is automatically normalised to
|
|
`Tank01.Temperature[]` by the gateway so the worker can resolve the full array.
|
|
You do not need to append `[]` in client code; the gateway handles it.
|
|
|
|
## Galaxy Repository browse
|
|
|
|
The Galaxy Repository service exposes a read-only browse over the AVEVA System
|
|
Platform Galaxy Repository (ZB SQL database). It uses the same API-key auth as
|
|
the gateway service but requires the `metadata:read` scope on the server.
|
|
|
|
[`GalaxyClient`](src/galaxy.rs) wraps the generated Galaxy bindings the same
|
|
way [`GatewayClient`](src/client.rs) wraps the gateway bindings:
|
|
|
|
```rust
|
|
let mut galaxy = GalaxyClient::connect(
|
|
ClientOptions::new("http://localhost:5000")
|
|
.with_api_key(ApiKey::new(api_key)),
|
|
).await?;
|
|
|
|
let ok = galaxy.test_connection().await?;
|
|
let last_deploy = galaxy.get_last_deploy_time().await?; // Option<prost_types::Timestamp>
|
|
let objects = galaxy.discover_hierarchy().await?; // Vec<GalaxyObject>
|
|
```
|
|
|
|
`get_last_deploy_time` returns `None` when the server reports
|
|
`present = false`. `discover_hierarchy` returns the generated
|
|
`GalaxyObject` proto type (re-exported via
|
|
`zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1`) with all attributes
|
|
attached.
|
|
|
|
The CLI ships matching subcommands under `galaxy`:
|
|
|
|
```powershell
|
|
cargo run -p mxgw-cli -- galaxy test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
|
cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
|
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
|
```
|
|
|
|
### Browsing lazily
|
|
|
|
For UI trees or OPC UA bridges, use `browse_children_raw` 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_raw(BrowseChildrenRequest::default()).await?;
|
|
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
|
|
server emits a bootstrap [`DeployEvent`](src/galaxy.rs) describing the
|
|
current cache state on subscribe, then one event each time the cached
|
|
`galaxy.time_of_last_deploy` changes. `sequence` is monotonic per server
|
|
start; gaps signal that the per-subscriber buffer dropped older events.
|
|
Pass `last_seen_deploy_time` to suppress the bootstrap event when the
|
|
client's cached deploy time matches the server's.
|
|
|
|
```rust
|
|
use futures_util::StreamExt;
|
|
|
|
let mut stream = galaxy.watch_deploy_events(None).await?;
|
|
while let Some(event) = stream.next().await {
|
|
let event = event?;
|
|
println!(
|
|
"seq={} objects={} attributes={}",
|
|
event.sequence, event.object_count, event.attribute_count,
|
|
);
|
|
}
|
|
// Drop the stream to cancel the gRPC call.
|
|
```
|
|
|
|
The matching CLI subcommand prints one line per event (`--json` switches to
|
|
one JSON object per event). `--last-seen-deploy-time` accepts an RFC3339
|
|
timestamp and is forwarded to the server. `--max-events` (default 0 = no
|
|
cap) lets you stop after a fixed number of events; otherwise the command
|
|
runs until the stream ends or `Ctrl+C` is pressed.
|
|
|
|
```powershell
|
|
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
|
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
|
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T15:30:00Z
|
|
```
|
|
|
|
## Integration Checks
|
|
|
|
Run live checks only when a gateway and MXAccess-backed worker are available:
|
|
|
|
```powershell
|
|
$env:MXGATEWAY_INTEGRATION = '1'
|
|
$env:MXGATEWAY_ENDPOINT = 'http://127.0.0.1:5000'
|
|
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
|
|
$env:MXGATEWAY_TEST_ITEM = 'TestChildObject.TestInt'
|
|
cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
|
```
|
|
|
|
## Related Documentation
|
|
|
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
|
- [Rust Client Detailed Design](./RustClientDesign.md)
|
|
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
|
|
|
## Installing from the Gitea Cargo registry
|
|
|
|
The crate publishes to the internal Gitea Cargo registry. Register the
|
|
registry once in your global `~/.cargo/config.toml`:
|
|
|
|
```toml
|
|
[registries.dohertj2-gitea]
|
|
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
|
```
|
|
|
|
Authentication: cargo reads credentials from `~/.cargo/credentials.toml`:
|
|
|
|
```toml
|
|
[registries.dohertj2-gitea]
|
|
token = "Bearer <your-gitea-token>"
|
|
```
|
|
|
|
Then add the dependency:
|
|
|
|
```toml
|
|
[dependencies]
|
|
zb-mom-ww-mxgateway-client = { version = "0.1.1", registry = "dohertj2-gitea" }
|
|
```
|