Files
mxaccessgw/clients/rust/README.md
T
Joseph Doherty 37cb3b0df8 fix(CLI-45): standardize the CLI credential env var and fail fast on empty passwords
All five client CLIs now share one credential contract for `authenticate-user`:
flags `--password` / `--password-env` (Go: `-password` / `-password-env`) with
default env `MXGATEWAY_VERIFY_PASSWORD`, resolution flag-then-env, and a resolved
credential that is missing *or empty* is a usage error naming the flag and the
variable. The value is never echoed and never reaches the wire.

Go and Java previously sent an empty credential when the variable was unset,
turning a misconfigured environment into a real MXAccess authentication attempt.
Go now returns the guard error before dialing; Java throws a picocli
ParameterException instead of falling back to "". Python's `--password-env`
gained the canonical default and its UsageError names the resolved variable.
Rust treats an empty flag or env value as missing, with the resolution extracted
into a testable `resolve_verify_user_password`. .NET adopts the canonical flags
and keeps `--verify-user-password`, `--verify-user-password-env`, and
MXGATEWAY_VERIFY_USER_PASSWORD as deprecated aliases for one release.

Docs same commit: CrossLanguageSmokeMatrix.md gains the credential contract and
the per-CLI subcommand-coverage table (the documented-not-fixed half of the
finding); all five READMEs name the canonical variable and the fail-fast rule,
and the .NET README carries the deprecation note. Tracking flipped to Done in
both remediation registers with a change-log row.

No .proto changed; no generated code regenerated.
2026-08-07 06:05:00 -04:00

428 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
variable named by `--password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, never
echoed) and `write-secured`. The credential is required: a missing or empty
resolved value is a usage error naming the flag and the variable, so the CLI
fails before dialing instead of authenticating with an empty password.
`MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client
CLIs — see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md).
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" }
```