Files
mxaccessgw/clients/go/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

503 lines
20 KiB
Markdown

# Go Client
The Go client module contains the generated MXAccess Gateway protobuf bindings,
a small handwritten `mxgateway` package, and the `mxgw-go` test CLI scaffold.
The module uses the shared proto inputs documented in
`../../docs/ClientProtoGeneration.md` so gateway and client contracts stay in
sync.
## Layout
```text
clients/go/
go.mod
generate-proto.ps1
internal/generated/
mxgateway/
cmd/mxgw-go/
```
`internal/generated` contains code produced by `protoc`, `protoc-gen-go`, and
`protoc-gen-go-grpc`. Do not edit generated files by hand.
## Regenerating Protobuf Bindings
Run generation after the shared `.proto` files or the Go output path changes:
```powershell
./generate-proto.ps1
```
The script uses the tool paths recorded in `../../docs/ToolchainLinks.md`.
## Build And Test
Run the Go module checks from `clients/go`:
```powershell
go test ./...
go build ./...
go vet ./...
```
The tests parse the shared JSON fixtures, exercise value and status conversion,
use `bufconn` for fake gateway auth and streaming behavior, and cover CLI JSON
redaction.
## Packaging
Build a local CLI executable from `clients/go`:
```powershell
New-Item -ItemType Directory -Force ../../artifacts/clients/go | Out-Null
go build -o ../../artifacts/clients/go/mxgw-go.exe ./cmd/mxgw-go
```
Install the CLI into the active `GOBIN` or `GOPATH/bin`:
```powershell
go install ./cmd/mxgw-go
```
Other Go modules can consume the library package with the module path
`gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway`.
## Client API
Use `mxgateway.Dial` with `mxgateway.Options` to configure plaintext or TLS
transport, API-key metadata, dial timeout, and per-call timeout:
```go
client, err := mxgateway.Dial(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
```
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`, `AdviseSupervisory`, `Write`, `WriteSecured`,
`WriteSecured2`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`,
`SetBufferedUpdateInterval`, `Suspend`, `Activate`, `Events`, and `Close`. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
### Reconnect-replay gap
Each `EventResult` carries exactly one of `Event`, `ReplayGap`, or `Err`. When
you resume a stream with `EventsAfter`/`SubscribeEventsAfter` and a non-zero
`afterWorkerSequence`, the gateway replays buffered events from that point. If
the requested sequence predates the oldest event still retained in its replay
ring, it delivers a single **replay-gap sentinel** at the head of the resumed
stream: `res.ReplayGap` is non-nil (`res.Event` is nil, `res.IsReplayGap()` is
true) and normal events follow it.
A gap means events were lost, so any locally cached tag/alarm state is now
stale. On seeing it, discard your cached state and re-snapshot. To resume
without provoking another gap, reconnect from just before the oldest retained
sequence:
```go
for res := range events {
switch {
case res.Err != nil:
// terminal: stream ended (see ErrSlowConsumer for the overflow case)
return res.Err
case res.IsReplayGap():
gap := res.ReplayGap
log.Printf("replay gap: requested after %d, oldest available %d; re-snapshotting",
gap.GetRequestedAfterSequence(), gap.GetOldestAvailableSequence())
resnapshot()
// to resume cleanly, reconnect with:
// session.EventsAfter(ctx, gap.GetOldestAvailableSequence()-1)
default:
handle(res.Event)
}
}
```
The gateway sets `ReplayGap` only on `StreamEvents` results (never on a fresh,
non-resumed stream and never on `DrainEvents`). The client makes the gateway's
sentinel typed and observable; it never synthesizes or swallows it.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
call returns a `StreamAlarmsClient`; cancel its context to terminate the
stream. All three pass straight through to the gateway's central alarm
monitor.
## 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 `AuthenticateUser`
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 (`AuthenticateUser``WriteSecured`/`WriteSecured2`) 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 `userID` on a plain write is ignored.
The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`:
```go
err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
// ...
err = session.Write(ctx, serverHandle, itemHandle, value, userID)
```
The CLI exposes the same command as `advise-supervisory`, and `write`
takes `-user-id`.
### Secured writes and user authentication
The verified/secured path has typed single-item helpers too:
`AuthenticateUser(ctx, serverHandle, verifyUser, verifyUserPassword)` returns the
resolved MXAccess user id, and `WriteSecured` / `WriteSecured2` issue the secured
write. Credentials passed to `AuthenticateUser`, and the string content of a
`WriteSecured`/`WriteSecured2` value, are kept out of any error the client
surfaces (they route through the same redaction seam as the API key) and are
never logged — callers must likewise keep them out of their own logs. MXAccess
parity holds: a `WriteSecured` issued without a matching prior `AuthenticateUser`
and supervisory advise fails natively, and that failure is surfaced unchanged
rather than pre-empted. The CLI exposes `authenticate-user` (credential via
`-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, or `-password`) 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).
### 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.
`Session.WriteArrayElements` offers a default-fill shorthand: pass only the
indices you want to set along with a `totalLength`. The gateway expands the
sparse representation into a full array before forwarding to MXAccess — every
unmentioned index receives the element type's zero value (boolean `false`,
integer `0`, float `0.0`, string `""`, time = Unix epoch). This is a **RESET**
of unmentioned indices, not a preserve of existing values. Use the full-array
form (read-modify-write) when existing element values must be preserved.
```go
// Set element [3] of a 10-element float array; all other indices reset to 0.0.
err = session.WriteArrayElements(
ctx,
serverHandle, itemHandle,
mxgateway.DataTypeFloat,
10, // totalLength
map[uint32]*mxgateway.MxValue{
3: mxgateway.FloatValue(1.5),
},
userID,
)
```
`AddItem`, `AddItem2`, `AddItemBulk`, and `AddBufferedItem` auto-normalize a
bare array attribute name to the `[]` array address form expected by MXAccess,
so callers do not need to append `[]` themselves. Both forms are accepted;
duplicates are deduplicated by the gateway.
## Galaxy Repository browse
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
read-only metadata-only browse over the AVEVA System Platform Galaxy
Repository. It uses the same API-key authentication as the MXAccess Gateway
and requires the `metadata:read` scope. Use `mxgateway.DialGalaxy` to obtain a
`*GalaxyClient` that mirrors the connection-management conventions of
`Client`:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
return err
}
defer galaxy.Close()
ok, err := galaxy.TestConnection(ctx)
deployTime, present, err := galaxy.GetLastDeployTime(ctx)
objects, err := galaxy.DiscoverHierarchy(ctx)
```
`GetLastDeployTime` returns `(time.Time{}, false, nil)` when the server
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
bootstrap event with the current Galaxy state immediately on subscribe, then
one `DeployEvent` per new deploy. `Sequence` is monotonic per server start;
gaps signal dropped events. Pass a non-nil `lastSeenDeployTime` to suppress the
bootstrap event when resuming from a known checkpoint:
```go
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
events, errs, err := galaxy.WatchDeployEvents(streamCtx, nil)
if err != nil {
return err
}
for {
select {
case ev, ok := <-events:
if !ok {
return nil // stream completed (server EOF or ctx cancelled)
}
log.Printf("seq=%d objects=%d attrs=%d",
ev.GetSequence(), ev.GetObjectCount(), ev.GetAttributeCount())
case streamErr := <-errs:
if streamErr != nil {
return streamErr // *GatewayError
}
case <-ctx.Done():
return ctx.Err()
}
}
```
Cancel the supplied context to tear down the stream cleanly. Both channels
close after EOF, cancellation, or a terminal error; surfaced errors are wrapped
in `*GatewayError`.
The CLI exposes the same RPC via `galaxy-watch`:
```powershell
go run ./cmd/mxgw-go galaxy-watch -plaintext
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
```
The command runs until Ctrl+C (or the optional `-limit` is reached) and prints
one line per event in text mode or one JSON object per event with `-json`.
## CLI
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
the gateway:
### Subcommand reference
Every subcommand wired into the CLI. All accept the common flags
(`-endpoint`, `-plaintext`, `-api-key` / `-api-key-env`, `-ca-cert`,
`-server-name-override`, `-call-timeout`) and most accept `-json`.
| Command | Purpose |
|---|---|
| `version` | Print client/contract versions. |
| `open-session` | Open a gateway session and print its id. |
| `close-session` | Close a session by id. |
| `ping` | Round-trip a `PING` command (`-session-id`, `-message`). |
| `register` | Register a client name on a session (`-session-id`, `-client-name`). |
| `add-item` | Add an item handle (`-session-id`, `-server-handle`, `-item`). |
| `advise` | Advise (subscribe) one item (`-session-id`, `-server-handle`, `-item-handle`). |
| `advise-supervisory` | Advise one item supervisory — required before a user-id-attributed plain `write`. |
| `subscribe-bulk` | Advise many items in one call. |
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
| `read-bulk` | Read snapshots for many item handles in one call. |
| `write` | Write one value (`-type`, `-value`). |
| `write-secured` | Secured single-item write (`-current-user-id`, `-verifier-user-id`, `-type`, `-value`). |
| `authenticate-user` | Authenticate a user, printing the resolved user id (`-verify-user`, `-password-env`/`-password`). |
| `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
| `write-secured2-bulk` | Secured bulk write with a shared timestamp. |
| `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). |
| `acknowledge-alarm` | Acknowledge an alarm reference. |
| `smoke` | End-to-end smoke workflow against one item. |
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
| `galaxy-last-deploy` | Print the most recent deploy event. |
| `galaxy-discover` | Discover deployed objects. |
| `galaxy-watch` | Stream deploy events until Ctrl+C or `-limit`. |
| `galaxy-browse` | Lazy/eager browse of the Galaxy object tree. |
| `batch` | Read commands from stdin (see below). |
```powershell
go run ./cmd/mxgw-go version -json
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
go run ./cmd/mxgw-go ping -session-id <id> -plaintext -json
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
go run ./cmd/mxgw-go write-bulk -session-id <id> -server-handle 1 -item-handles 1,2 -values 10,20 -type int32 -plaintext -json
go run ./cmd/mxgw-go read-bulk -session-id <id> -item-handles 1,2 -plaintext -json
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
go run ./cmd/mxgw-go stream-alarms -plaintext -json
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-browse -plaintext -json
```
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
enabled. CLI output redacts the key value and never writes the raw secret.
### `batch` mode
`batch` reads one command line at a time from stdin and dispatches each through
the same routing as the standalone subcommands; it is the interface the
cross-language E2E harness drives. After every command's output it writes the
end-of-result sentinel line `__MXGW_BATCH_EOR__` to stdout and flushes, so the
harness can frame each result. Blank/whitespace-only lines are skipped; only
stdin EOF ends the session. Command errors are serialised as a JSON object
(`{"error":...,"type":"error"}`) to stdout (not stderr) and still followed by the
sentinel, so a failing command does not abort the batch. The input scanner
buffer is widened to 16 MiB so a single long command line (e.g. a bulk write with
thousands of handles) does not trip bufio's default 64 KiB token-too-long limit;
a line that still exceeds 16 MiB surfaces as a framed error and ends the session.
Use TLS options for a secured gateway:
```powershell
go run ./cmd/mxgw-go smoke -endpoint mxgateway.example.local:5001 -ca-cert C:\certs\mxgateway-ca.pem -server-name-override mxgateway.example.local -api-key-env MXGATEWAY_API_KEY -item Area001.Tag.Value -json
```
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
```powershell
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
```
## Installing the Go client
The module is resolved directly from the git repo — no package registry:
````bash
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.1
````
Then import:
````go
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
````
If your build environment cannot reach `gitea.dohertylan.com` directly,
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
repo, or set `GOPRIVATE=gitea.dohertylan.com/*` to fetch the module
straight from the VCS — this both bypasses the public module proxy and
disables checksum-database (`sum.golang.org`) verification for that path.
Add `GOINSECURE=gitea.dohertylan.com/*` if the host serves the module over
plain HTTP rather than HTTPS.
## Releasing a new version
Go modules in monorepo subdirectories use prefixed tags. To tag a release
from this repo:
````bash
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
````
The script validates semver, refuses to tag with uncommitted tracked
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
pushes it to origin.
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Go Client Detailed Design](./GoClientDesign.md)