Compare commits

..

314 Commits

Author SHA1 Message Date
Joseph Doherty 8df5ab381a docs(stillpending): mark §8 items resolved by section8-completion branch 2026-06-16 17:42:42 -04:00
Joseph Doherty 6f21d926d7 test(java-cli): cover galaxy-discover/galaxy-watch over in-process harness 2026-06-16 17:32:08 -04:00
Joseph Doherty aa8ae6613b refactor(java-cli): drop dead no-arg GalaxyCommand ctors (Task 3 review) 2026-06-16 17:28:43 -04:00
Joseph Doherty 44d676aede test(server): restore entireProcessTree kill assertion in WorkerProcessLauncherTests (Task 9 review) 2026-06-16 17:22:46 -04:00
Joseph Doherty e2b1a6686a feat(java-cli): inject GalaxyClientFactory seam (behavior-preserving default) 2026-06-16 17:18:47 -04:00
Joseph Doherty 01bdb484de refactor(tests): consolidate FakeWorkerProcess onto TestSupport canonical 2026-06-16 17:14:03 -04:00
Joseph Doherty 3bb4d5a082 test(java-cli): cover stream-events over in-process harness 2026-06-16 17:09:44 -04:00
Joseph Doherty 4ab3bd55e5 fix(server): single-clock poll loop + drop dead sync GetReadyWorkerClient (Task 8 review) 2026-06-16 17:04:18 -04:00
Joseph Doherty 70d2842c16 test(java-cli): add in-process gRPC harness fixture 2026-06-16 16:55:48 -04:00
Joseph Doherty 4966ef3359 feat(server): bounded worker-ready wait in GatewaySession (default off) 2026-06-16 16:48:02 -04:00
Joseph Doherty 0efa7d8cca test(java-cli): cover secured/secured2/bench bulk + close-session 2026-06-16 16:42:28 -04:00
Joseph Doherty ea17528767 feat(server): add MxGateway:Sessions:WorkerReadyWaitTimeoutMs (default off)
Adds WorkerReadyWaitTimeoutMs to SessionOptions (default 0 = disabled),
validates >= 0 in GatewayOptionsValidator, documents it in
GatewayConfiguration.md, and adds validator + default-value tests.
No wait/poll logic is implemented here (that is Task 8).
2026-06-16 16:38:31 -04:00
Joseph Doherty 1cfad83c06 test(java-cli): cover read-bulk/write-bulk/write2-bulk round trips 2026-06-16 16:33:42 -04:00
Joseph Doherty 76ffd5c9a3 docs: implementation plan for stillpending §8 completion (9 tasks) 2026-06-16 16:29:49 -04:00
Joseph Doherty 6030bfa18e docs: design for stillpending §8 completion (Approach C)
Also codify targeted-test-per-task rule in CLAUDE.md Source Update Workflow.
2026-06-16 16:19:49 -04:00
Joseph Doherty 82755a3623 docs(stillpending): reflect session-resilience merge (multi-subscriber resolved, reconnect core) + disable-login flag 2026-06-16 15:53:11 -04:00
Joseph Doherty 121ab7e263 fix(galaxy): include undeployed areas in browse + re-root orphaned objects
The hierarchy query returned deployed objects only (deployed_package_id <> 0), so
areas whose containing area is undeployed were orphaned and hidden from /browse —
on wonder, only the lone deployed root area surfaced. Include category-13 Area
objects regardless of deployment, and in GalaxyHierarchyIndex re-root any object
whose parent is absent from the set (e.g. a deleted container area) so nothing
disappears under a phantom parent id.
2026-06-16 12:49:03 -04:00
Joseph Doherty ca443b1903 test+docs(dashboard): assert hub policy under disable-login; correct warning/doc wording 2026-06-16 09:30:13 -04:00
Joseph Doherty 7a2da4d8b6 chore(plan): mark dashboard disable-login tasks complete 2026-06-16 09:23:50 -04:00
Joseph Doherty a0b21ca225 refactor(dashboard): consistent effective-user in disable-login warning; doc the config read 2026-06-16 09:23:13 -04:00
Joseph Doherty 3690e4c2ca feat(dashboard): swap to auto-login handler when DisableLogin is set 2026-06-16 09:14:27 -04:00
Joseph Doherty 1d652b24c6 refactor(dashboard): normalize auto-login user in ctor; clarify claim-shape doc; add custom-user test 2026-06-16 08:23:14 -04:00
Joseph Doherty ee1423db7a docs: document dashboard DisableLogin / AutoLoginUser dev flag 2026-06-16 08:16:45 -04:00
Joseph Doherty 4993057ed5 feat(dashboard): add auto-login auth handler for DisableLogin mode 2026-06-16 08:14:51 -04:00
Joseph Doherty 073252d7a6 feat(dashboard): add DisableLogin + AutoLoginUser options (default off) 2026-06-16 08:11:10 -04:00
Joseph Doherty a894717319 docs(plan): dashboard disable-login implementation plan + tasks 2026-06-16 07:54:43 -04:00
Joseph Doherty 0856cd4f93 docs: dashboard disable-login design + save session-resilience tasklist snapshot 2026-06-16 07:47:31 -04:00
Joseph Doherty c446bef64f chore(plan): mark session-resilience tasks 1-12 complete 2026-06-16 07:29:17 -04:00
Joseph Doherty c7a7cd1e5e fix(sessions): tidy replay filter/comment; zero OldestAvailableSequence when no gap
- EventStreamService: remove dead per-item sequence guard in the replay
  loop (RegisterWithReplay already returns only events > afterSequence)
  and correct the comment that falsely claimed a "per-item constraint
  filter" is applied; the event stream has no per-event constraint
  filtering today.
- SessionEventDistributor.RegisterWithReplay: set oldestAvailableSequence=0
  when gap==false so the implementation matches the documented contract
  (OldestAvailableSequence is meaningful only when Gap is true).
  Update the two RegisterWithReplay tests that asserted the old non-zero
  value in the no-gap path.
- RegisterSubscriber: remove stray blank line at method entry.
- SessionEventDistributorTests: add RegisterWithReplay_AfterDispose_
  ThrowsObjectDisposedException to pin nested-lock disposal behavior.
2026-06-16 07:28:37 -04:00
Joseph Doherty 36ab8d15f1 feat(sessions): replay-on-reconnect with ReplayGap sentinel 2026-06-16 07:22:19 -04:00
Joseph Doherty 042f5e3d82 fix(sessions): expose DetachGraceSeconds in effective-config; single clock; close reconnect-vs-sweep race
- EffectiveSessionConfiguration: add DetachGraceSeconds field; GatewayConfigurationProvider
  forwards value.Sessions.DetachGraceSeconds (blocker fix).
- GatewaySession.InvokeAsync and ReadEventsAsync: switch TouchClientActivity calls from
  DateTimeOffset.UtcNow to _eventStreaming.TimeProvider.GetUtcNow() so Task 12 fake-clock
  control works end-to-end (split-clock fix).
- TOCTOU fix: add TryBeginCloseIfExpired(now, out alreadyClosing) to GatewaySession that
  re-checks IsLeaseExpiredCore/IsDetachGraceExpiredCore AND _activeEventSubscriberCount==0
  under _syncRoot before transitioning to Closing; CloseExpiredLeasesAsync calls it before
  CloseSessionCoreAsync so a reattach that wins the race leaves the session Ready/usable.
- Minors: lease-expiry-takes-precedence comment in CloseExpiredLeasesAsync; TOCTOU comment
  block; sweep-cycle latency note added to SessionOptions.DetachGraceSeconds XML doc and to
  GatewayConfiguration.md DetachGraceSeconds row.
- New tests: TryBeginCloseIfExpired_ReattachedSubscriberWinsRace_DeclinesClose (GatewaySession),
  CloseExpiredLeasesAsync_DoesNotCloseSessionThatReattachedBeforeSweepCloses (SessionManager),
  plus IsLeaseExpiredCore/IsDetachGraceExpiredCore private helpers used by the guard.
2026-06-16 07:11:59 -04:00
Joseph Doherty db95f8644f feat(sessions): detach-grace retention window for reconnect 2026-06-16 06:15:46 -04:00
Joseph Doherty 85e4334bb7 docs(contracts): clarify ReplayGap drain-exclusion and resume-boundary semantics
Add two comment-only clarifications to mxaccess_gateway.proto (no field/number changes):
1. MxEvent.replay_gap: states the sentinel is ONLY ever set on StreamEvents events
   and is ALWAYS unset on DrainEventsReply events, preventing Task 12 from
   accidentally emitting it on the drain path and removing any client ambiguity.
2. ReplayGap.oldest_available_sequence: clarifies that the value IS retained and
   replayable, and that a client resumes gap-free by setting
   after_worker_sequence = oldest_available_sequence - 1 in the next
   StreamEventsRequest (receiving events starting at oldest_available_sequence).
Regenerated Generated/MxaccessGateway.cs (comment-only XML-doc change).
2026-06-16 05:09:47 -04:00
Joseph Doherty 9beb67c1e9 feat(contracts): add ReplayGap signal to MxEvent for reconnect replay 2026-06-16 05:04:17 -04:00
Joseph Doherty 056bb39a4d test(gateway): deterministic multi-subscriber test sync + cap-rejection specificity
Replace Task.Delay(100) subscriber-attachment races with WaitForSubscriberCountAsync,
a polling gate on GatewaySession.ActiveEventSubscriberCount so Advise and event fan-out
cannot proceed until all subscribers are confirmed registered.

Fix WaitForMessageCountAsync to honor a single CancellationTokenSource deadline across
the poll loop rather than resetting the timeout on each intermediate wakeup.

Add ordering comment in the cancellation test explaining why stream1Task must be awaited
before AllowNextEvent to guarantee sub1 is unregistered before the 2nd event is fanned.

Assert capException.Status.Detail contains "maximum" in the cap test to distinguish
EventSubscriberLimitReached (AllowMultiple=true cap) from EventSubscriberAlreadyActive
(single-subscriber rejection) — both map to ResourceExhausted.

Extract shared ConfigureCommandReply helper and move FakeWorkerProcess to TestSupport/
so both fake-worker test classes reference one definition.
2026-06-15 16:34:12 -04:00
Joseph Doherty 9dd97a27f1 test(gateway): end-to-end multi-subscriber fan-out via fake worker
Adds GatewayEndToEndMultiSubscriberTests covering three scenarios
through the real gRPC StreamEvents path with AllowMultipleEventSubscribers=true:
- Fan-out: two concurrent StreamEvents RPCs both receive every event the fake
  worker emits, in the same order (WorkerSequence matches, values indexed).
- Independent cancellation: cancelling one subscriber's stream leaves the other
  receiving subsequent events; the session stays usable.
- Cap enforcement: with MaxEventSubscribersPerSession=2 a third concurrent
  StreamEvents is rejected with gRPC ResourceExhausted while the first two
  keep streaming.

Extends RecordingServerStreamWriter<T> with WaitForMessageCountAsync to
allow deterministic bounded-timeout awaits for an N-message count without
fixed sleeps.
2026-06-15 16:09:58 -04:00
Joseph Doherty 281e00b300 refactor(sessions): derive subscriber mode from session config; close Task 8 review nits
Remove the per-call allowMultipleSubscribers param from AttachEventSubscriber and
derive the mode internally from _eventStreaming.AllowMultipleEventSubscribers — the
same source SessionEventDistributor uses for singleSubscriberMode — so the two can
never structurally diverge. The maxSubscribers cap param is kept because
MaxEventSubscribersPerSession lives in SessionOptions, which the session does not hold
directly (only EventOptions flows through SessionEventStreaming).

Other nits:
- SubscriberCount XML doc clarifies it includes internal subscribers and differs from
  GatewaySession.ActiveEventSubscriberCount (external/gRPC only).
- SingleSubscriberMode_LoneExternalOverflow test: add Assert.Equal(1, observedSet) guard
  before the value assertion so the test cannot pass vacuously if the handler never fired.
- GatewayOptionsValidator.ValidateSessions: add explanatory code comment documenting why
  !AllowMultipleEventSubscribers && MaxEventSubscribersPerSession > 1 is NOT rejected as
  a hard error (the default config ships with this combination; the cap is simply unused
  in single-subscriber mode, not a behavior bug).
- GatewaySession.DetachEventSubscriber: add Debug.Assert before the clamp so a genuine
  double-decrement surfaces in debug builds.
2026-06-15 15:53:27 -04:00
Joseph Doherty ac42783e36 feat(sessions): multi-subscriber cap enforcement + mode-gated FailFast 2026-06-15 15:32:08 -04:00
Joseph Doherty 7b12eebbd1 docs: add MaxEventSubscribersPerSession to config shape example; assert its default
Add MaxEventSubscribersPerSession (value 8) to the Sessions block of the
Configuration Shape JSON example in GatewayConfiguration.md, matching the
appsettings.json default the options table already documents. Assert both
MaxEventSubscribersPerSession (8) and MaxPendingCommandsPerSession (128)
defaults in GatewayOptionsTests.OptionsBinding_UsesDesignDefaults.
2026-06-15 15:16:44 -04:00
Joseph Doherty bd190ab012 feat(config): allow multiple event subscribers + add MaxEventSubscribersPerSession cap
Remove the hard-rejection of AllowMultipleEventSubscribers=true in GatewayOptionsValidator
(fan-out is now implemented via SessionEventDistributor). Add MaxEventSubscribersPerSession
(default 8, must be >= 1) to SessionOptions, validate it, expose it in
EffectiveSessionConfiguration / GatewayConfigurationProvider, document it in
GatewayConfiguration.md and appsettings.json. Tests cover the no-error path for
AllowMultipleEventSubscribers=true, the 0/-1 rejection, positive pass, and default pass.
2026-06-15 15:13:21 -04:00
Joseph Doherty 2ead9bc200 fix(dashboard): close StartDashboardMirror/DisposeAsync race; internal-overflow test + metric label
(1) GatewaySession.StartDashboardMirror: publish _dashboardMirrorLease and _dashboardMirrorTask
    atomically under one _syncRoot section; if the session is already Closing/Closed/Faulted,
    dispose the just-created lease and return without starting the mirror task so nothing is orphaned.
(2) WaitUntilAsync test helper: catch OperationCanceledException and call Assert.Fail with the
    timeout duration and predicate source text instead of letting the exception propagate raw.
(3) New SessionEventDistributorTests.InternalSubscriberOverflow_HandlerSeesIsOnlySubscriberFalse:
    verifies CountExternalSubscribers excludes the internal subscriber, so isOnlySubscriber==false
    even when the internal subscriber is the only registered subscriber.
(4) SubscriberOverflowHandler delegate gains isInternal parameter; overflow metric label is
    "dashboard-mirror" for internal subscribers and "grpc-event-stream" for external ones.
(5) DashboardEventBroadcaster.Publish: wrap SendAsync Task acquisition in try/catch so a
    synchronous throw cannot escape the never-throw Publish interface contract.
2026-06-15 15:02:36 -04:00
Joseph Doherty 1ea08c3b10 feat(dashboard): mirror events via SessionEventDistributor subscriber (fixes dark feed without gRPC client) 2026-06-15 14:42:32 -04:00
Joseph Doherty 4f43733b96 test(sessions): document overflow race safety + close backpressure coverage gaps
- Issue 1: document the isOnlySubscriber snapshot race-safety assumption in
  OnSubscriberOverflow; flags the Task 7/8 revisit point explicitly.
- Issue 2: pin StreamDisconnects==1 in the FailFast overflow test so a
  regression dropping the StreamDisconnected("Detached") finally call is caught.
- Issue 3: replace plain int/bool? reads in SlowSubscriberOverflow test with
  Volatile.Read/Write + Interlocked.Increment stores to close the C# memory
  model data race on overflowCalls and observedIsOnlySubscriber.
- Issue 4: add SlowSubscriberOverflow_WithMultipleSubscribers_... distributor
  test pinning that isOnlySubscriber==false disables the session-fault path;
  includes TODO(Task 8) note for the GatewaySession-level assertion.
- Issue 5: reword SubscriberOverflowHandler XML doc to make explicit that the
  handler must NOT complete the subscriber's channel; the distributor owns that.
2026-06-15 13:46:37 -04:00
Joseph Doherty 039111ca05 feat(sessions): per-subscriber backpressure isolation in SessionEventDistributor 2026-06-15 13:39:25 -04:00
Joseph Doherty 61627fc5b0 fix(sessions): make EventSubscriberLease dispose atomic; dedupe lease dispose
Issue 1: replace plain bool _disposed in EventSubscriberLease with an
Interlocked.Exchange int (_leaseDisposed) matching the SubscriberLease
pattern in SessionEventDistributor. Concurrent stream-completion +
client-cancellation racing Dispose() now decrements _activeEventSubscriberCount
exactly once, never to -1.

Issue 5: remove the `using` declaration on the subscriber lease in
EventStreamService.StreamEventsAsync; the finally block already disposes it
alongside the reader, so the using was a redundant second dispose on the
same code path.

Issue 2: add an inline comment at the StartAsync().GetAwaiter().GetResult()
call documenting the sync-over-async invariant (StartAsync only schedules via
Task.Run and is synchronous; do not make it truly async without changing
this call site).

Issue 10: remove the redundant .WithCancellation(cancellationToken) chained
on ReadEventsAsync(cancellationToken) in MapWorkerEventsAsync; the
[EnumeratorCancellation] token already flows through the direct argument.

Issue 9: add EventSubscriberLease_ConcurrentDispose_DecrementsCountExactlyOnce
to GatewaySessionTests — 16 concurrent Dispose() calls on the same lease for
200 iterations; asserts count is exactly 0 after each race and a subsequent
single-subscriber AttachEventSubscriber succeeds.
2026-06-15 13:29:27 -04:00
Joseph Doherty 7f1018bac1 feat(sessions): route event streaming through SessionEventDistributor 2026-06-15 13:18:28 -04:00
Joseph Doherty c2c518862f fix(sessions): replay-buffer gap edge cases, effective-config exposure, capacity-0 tests
#2: Replace afterSequence+1<oldestRetained with overflow-safe oldestRetained>0&&afterSequence<oldestRetained-1 to prevent ulong wrap at MaxValue falsely reporting gap=true.
#3: Add ReplayBufferCapacity and ReplayRetentionSeconds to EffectiveEventConfiguration and populate from EventOptions in GatewayConfigurationProvider.
#4: Add four new SessionEventDistributorTests covering capacity=0 gap/no-gap paths and the ulong.MaxValue boundary case.
#5: Update class-level <remarks> to describe the Task 3 replay ring buffer (capacity + age eviction, TryGetReplayFrom) rather than its absence.
#6: Add O(n)-is-acceptable comment at TryGetReplayFrom linear scan.
#8: Narrow no-replay 4-arg ctor to internal; InternalsVisibleTo already covers the test project.
2026-06-15 12:48:11 -04:00
Joseph Doherty e962737d2c feat(sessions): add bounded replay ring buffer to SessionEventDistributor 2026-06-15 12:42:15 -04:00
Joseph Doherty 7773bdebbd fix(sessions): close SessionEventDistributor dispose/register races + add overflow logging 2026-06-15 12:37:39 -04:00
Joseph Doherty c79b292968 feat(sessions): add SessionEventDistributor (pump + per-subscriber fan-out skeleton) 2026-06-15 12:32:13 -04:00
Joseph Doherty a43b2ee6af test(sessions): cover OwnerKeyId service-layer forwarding; doc 11-param ctor
Add LastOwnerKeyId capture to FakeSessionManager and assert it equals
"operator01" in OpenSession_WithValidRequest_ReturnsSessionDetails, closing
the gap where OwnerKeyId threading through the service layer had no test
coverage. Add a <remarks> to the 11-param GatewaySession convenience ctor
documenting that OwnerKeyId is null there and authenticated call sites must
use the 12-param overload.
2026-06-15 12:29:16 -04:00
Joseph Doherty f5479f3ca3 feat(sessions): record OwnerKeyId on session creation
Add a nullable string? OwnerKeyId property to GatewaySession that captures
the API key identifier (KeyId) of the authenticated caller that opened the
session. Wire it through ISessionManager.OpenSessionAsync → SessionManager
→ GatewaySession constructor. The gRPC service passes identityAccessor
.Current?.KeyId; internal callers (GatewayAlarmMonitor, DashboardLiveDataService)
pass null. Covers the positive and null cases with two new TDD-first tests.
2026-06-15 12:24:29 -04:00
Joseph Doherty 00c849e63b docs: session-resilience implementation plan (28 tasks, 5 phases) 2026-06-15 12:15:34 -04:00
Joseph Doherty 3fc6ccad30 docs: session-resilience epic design (fan-out, reconnect, ACL, reattach) 2026-06-15 12:11:46 -04:00
Joseph Doherty 0e4843612b feat(python): add --parent drill-down to galaxy-browse for 5/5 CLI parity
Add --parent-gobject-id (integer) to the galaxy-browse CLI command so the
Python client matches the Go (-parent) and Rust (--parent-gobject-id) CLIs.
When set, drives BrowseChildren paging via browse_children_raw (page size 500,
repeated-token guard) and renders the same JSON node shape (flattened object
fields + hasChildrenHint + empty children array) and indented-text tree as the
root-walk path. --depth is ignored on the parent path with a one-line stderr
warning, matching the Go/Rust behaviour. Tests added in TDD order.
2026-06-15 11:45:44 -04:00
Joseph Doherty a56ce0ddbd docs: refresh stillpending.md for completed work; record residuals (§7/E2)
Mark §1.1 (11 worker commands), §1.2 (audit CorrelationId), and §4 client
CLI/helper parity as Resolved with commit refs; correct §4.4 (dotnet version
already worked). Record open residuals: §1.3 live failover counter, §3.2
multi-sample buffered conversion, §1.4 vendor-stub ack, DrainEvents snapshot
semantics.
2026-06-15 11:35:48 -04:00
Joseph Doherty f7ada90359 test(integration): harden B8 live assertions (ArchestrAUserToId user_id, bootstrap arrival, split guard)
Fix 1 (Important): assert ArchestrAUserToId Ok-path payload carries a non-zero user_id, mirroring the AuthenticateUser pattern.
Fix 2 (Important): assert bootstrapBufferedEvents > 0 before the residual return so the "empty NoData bootstrap arrives" claim is verified, not just assumed.
Fix 3 (Minor): change SplitLiveItemForBuffered guard from lastDot <= 0 to lastDot < 0 so a leading-dot reference ".TestInt" (lastDot==0) is not mis-handled as undotted.
2026-06-15 11:30:15 -04:00
Joseph Doherty efd99718d7 test(integration): live COM command smoke + buffered capture (B8) 2026-06-15 11:19:12 -04:00
Joseph Doherty b298ca74be fix(java): picocli ParameterException for browse --depth; warn on --parent 0
Replaces the raw IllegalArgumentException thrown by GalaxyBrowseCommand for
--depth < 0 with a CommandLine.ParameterException so picocli surfaces a clean
single-line error instead of an unhandled stack trace. Adds an upper bound of
50 (matching the Python client) so --depth > 50 is also rejected cleanly.

Emits a stderr warning when --parent 0 is supplied explicitly, matching
Go/Rust client behaviour, because gobject id 0 is the server's root-walk
sentinel and passing it via --parent is almost always a mistake.

Adds three new tests: negative depth, depth > 50, and the --parent 0 warning path.
2026-06-15 11:08:07 -04:00
Joseph Doherty 0d5b488c11 feat(java): add ping + galaxy-browse CLI subcommands and galaxy command aliases
- D4: add 'ping' subcommand (MX_COMMAND_KIND_PING / PingCommand{message}),
  accepting --session-id and optional --message (default "ping"); prints the
  worker's echoed diagnostic message.
- D8-java: add 'galaxy-browse' subcommand over browse()/LazyBrowseNode.expand()
  and raw BrowseChildren paging for --parent. JSON node shape matches the
  cross-client surface (flattened object fields + hasChildrenHint + nested
  children array).
- D9-java: make galaxy-test-connection / galaxy-last-deploy the primary names,
  keeping galaxy-test / galaxy-deploy-time as deprecated picocli aliases.
- Tests for ping, galaxy-browse JSON hasChildrenHint key, and alias resolution.
- README updated for the new/renamed subcommands.
2026-06-15 10:58:04 -04:00
Joseph Doherty bb5139fec2 test(gateway): fake worker responds to control commands (A6)
Add RespondToControlCommandAsync to FakeWorkerHarness so scripted fake
workers can auto-reply to the five control command kinds (Ping,
GetSessionState, GetWorkerInfo, DrainEvents, ShutdownWorker) with canned
replies whose shapes match the real WorkerPipeSession helpers.

Add five unit tests in FakeWorkerHarnessTests covering each control
command kind through the WorkerClient→pipe roundtrip, and one gateway
E2E test (GatewayService_WithFakeWorker_ControlCommandsRoundtripThroughGateway)
that exercises Ping, GetWorkerInfo, and DrainEvents through the full
gRPC→SessionManager→WorkerClient→named-pipe path using a scripted
ControlCommandFakeWorkerProcessLauncher.
2026-06-15 10:56:56 -04:00
Joseph Doherty dde9934e60 test(worker): silence CS0649 on reflection-only FakeMxStatus fields 2026-06-15 10:42:59 -04:00
Joseph Doherty 29399325d5 feat(worker): implement 6 MXAccess COM commands in executor
Wire up the previously-unimplemented Suspend, Activate, AuthenticateUser,
ArchestrAUserToId, AddBufferedItem, and SetBufferedUpdateInterval command
kinds in MxAccessCommandExecutor. These are real COM calls and run on the
STA via the executor.

- IMxAccessServer gains the 6 methods; MxAccessComServer routes them to the
  right interface version (Suspend/Activate -> ILMXProxyServer4 out MxStatus,
  AuthenticateUser -> base ILMXProxyServer, ArchestrAUserToId ->
  ILMXProxyServer2, AddBufferedItem/SetBufferedUpdateInterval ->
  ILMXProxyServer5).
- Suspend/Activate surface the native MxStatus, converted to MxStatusProxy
  via the existing MxStatusProxyConverter.
- AuthenticateUser hands the credential straight to MXAccess and never logs
  it; native HResult failures propagate via the dispatcher.
- MxAccessSession gains matching pass-throughs; AddBufferedItem registers
  the item handle in the handle registry.
- Unit tests (fake IMxAccessServer / fake COM object) cover each arm plus a
  password-non-leak assertion; existing IMxAccessServer fakes updated.

No proto changes (all request/reply messages already exist).
2026-06-15 10:41:22 -04:00
Joseph Doherty f94c206489 test(worker): use Register (a real STA command) for STA-dispatch race tests
Ping is now intercepted as a worker control command and answered on the
message-loop thread, so the dispatch/heartbeat/shutdown-race tests must use a
genuine STA-dispatched command kind to keep exercising DispatchAsync.
2026-06-15 10:25:34 -04:00
Joseph Doherty 72e1aca716 test(worker): fix control-command test helpers (CreatePipeSession overload, drop ConfigureAwait) 2026-06-15 10:23:45 -04:00
Joseph Doherty bf72cd8961 feat(worker): implement Ping/GetSessionState/GetWorkerInfo/DrainEvents/ShutdownWorker control commands
Answer the five worker control/lifecycle commands at the WorkerPipeSession
message-loop layer instead of the STA-bound MxAccessCommandExecutor. These
replies are built from process-level state (worker pid, assembly version,
worker lifecycle, the runtime session's event queue) the executor cannot see,
and ShutdownWorker must emit its OK reply before the graceful shutdown joins
the STA thread - dispatching it onto the STA would deadlock.

- Ping: OK reply, echoes message into diagnostic_message.
- GetSessionState: maps WorkerState to proto SessionState.
- GetWorkerInfo: pid, worker version, MXAccess ProgID/CLSID.
- DrainEvents: drains the runtime event queue into DrainEventsReply.
- ShutdownWorker: OK reply, then graceful shutdown, then stops the loop.

Tests added in WorkerPipeSessionTests; FakeRuntimeSession gains a
batch-size drain suppressor so DrainEvents does not race the background
drain loop.
2026-06-15 10:20:51 -04:00
Joseph Doherty 5a7f8ace77 fix(go): use hasChildrenHint JSON key for browse parity; warn on -parent 0
Rename the browse JSON key from hasChildren to hasChildrenHint to match the
Rust and Python CLIs and the library property name (HasChildrenHint). Update
the text-output label to match. Add a one-line stderr warning when -parent 0
is supplied, since 0 is the server root sentinel and omitting -parent is the
intended way to walk from the root.
2026-06-15 10:09:38 -04:00
Joseph Doherty c10faa2ee5 fix(dotnet): use hasChildrenHint JSON key for browse cross-client parity
Rename the anonymous-object member `hasChildren` → `hasChildrenHint` so the
serialized JSON key matches the Rust and Python CLIs and the library property
HasChildrenHint. Also update the text-output suffix to `hasChildrenHint=` for
consistency.
2026-06-15 10:09:36 -04:00
Joseph Doherty 7975b09325 fix(python): bound galaxy-browse --depth; assert no _text leak in JSON
Guard _galaxy_browse against unbounded recursion by rejecting --depth
values outside [0, 50] with a descriptive BadParameter. Add test coverage
for --depth 99 and --depth -1 rejection, and assert _text is never
present in the JSON output from galaxy-browse.
2026-06-15 10:09:30 -04:00
Joseph Doherty d7e2a8b3cf feat(dotnet): add galaxy-browse CLI (§4.6); chore: verify version subcommand (§4.4) 2026-06-15 10:07:24 -04:00
Joseph Doherty 39ec2a3275 feat(python): add galaxy-browse CLI subcommand (§4.6) 2026-06-15 10:00:52 -04:00
Joseph Doherty 8cb416ba30 feat(go): add galaxy-browse CLI subcommand (§4.6) 2026-06-15 10:00:36 -04:00
Joseph Doherty 55526d5e56 fix(gateway): preserve raw client correlation id in denial audit DetailsJson + add wiring test (§1.2) 2026-06-15 09:56:24 -04:00
Joseph Doherty a59fc998e3 fix(python): UTC-normalize galaxy-last-deploy output, add deploy-event collector, help text, test 2026-06-15 09:53:01 -04:00
Joseph Doherty 539e6ef2de fix(rust): warn when browse --depth ignored, extract page-size const, tidy clones
Warn on stderr when --parent-gobject-id and --depth>0 are both supplied
since depth is silently ignored in the single-level parent path. Also
updates the --depth arg doc to document this. Extracts BROWSE_PAGE_SIZE
const (500) with a cross-reference to galaxy.rs instead of a bare literal.
Removes three redundant .clone() calls in BrowseChildrenOptions construction
since the originals are not used after the struct is built.
2026-06-15 09:52:24 -04:00
Joseph Doherty 742ced7970 test(go): assert ping echo in JSON output; comment ping fallback
TestRunPingJSON now verifies the fake gateway's echoed text appears in
the serialised reply body, catching any future wiring regression that
maps PingRaw to the wrong proto field.  runPing gains a one-line comment
explaining why DiagnosticMessage carries the echo, why the kind-string
fallback exists, and why writeCommandOutput is not reused on the
plain-text path.
2026-06-15 09:52:13 -04:00
Joseph Doherty bd46ba1270 fix(test): drop removed logger arg from GalaxyRepositoryGrpcService test call sites; docs: STA phrasing
Remove the trailing NullLogger<GalaxyRepositoryGrpcService>.Instance argument
from all four CreateService/inline constructions in GalaxyRepositoryGrpcServiceTests
and GalaxyFilterInputSafetyTests, matching the now-4-param constructor after the
dead logger parameter was removed in 0032d2d. Also drop the now-unused
Microsoft.Extensions.Logging.Abstractions using from both files.

Rephrase the §5 STA blurb in docs/AlarmClientDiscovery.md: GatewayAlarmMonitor
routes polling *through* the worker's StaRuntime (which owns the STA pump) rather
than owning the pump itself.
2026-06-15 09:52:07 -04:00
Joseph Doherty 0032d2dc44 docs+chore: fix stale prose, project names, remove dead MapSqlException (§7)
- docs/plans/2026-06-14-deferred-followups.md: mark D1 as executed
  (commit 4af24b9; metric emitted at DashboardSnapshotService.cs:198);
  note D2 resolved as no-op; D3-D5 remain pending
- docs/AlarmClientDiscovery.md §5: rewrite STA "production fix needed"
  to past tense — alarms now route through GatewayAlarmMonitor/worker STA
- EventsHub.cs: replace stale "publisher side is a future follow-up"
  comment; DashboardEventBroadcaster is live and DI-registered
- CLAUDE.md: fix all project-name drift (src/MxGateway.* →
  src/ZB.MOM.WW.MxGateway.*; MxGateway.sln → ZB.MOM.WW.MxGateway.slnx;
  clients/dotnet/MxGateway.Client.sln → ZB.MOM.WW.MxGateway.Client.slnx)
- GalaxyRepositoryGrpcService.cs: remove dead MapSqlException method and
  its IDE0051 suppression pragma; drop now-unused ILogger ctor param and
  Microsoft.Data.SqlClient using; build confirmed 0 warnings/errors
2026-06-15 09:43:00 -04:00
Joseph Doherty 8415f35abd feat(gateway): thread ClientCorrelationId into constraint-denial audit (§1.2) 2026-06-15 09:42:40 -04:00
Joseph Doherty 639e36b1bc feat(rust): add browse CLI subcommand (§4.6) 2026-06-15 09:42:16 -04:00
Joseph Doherty 90529dce6e feat(go): add ping CLI subcommand (§4.3)
Add PingRaw to Session (session.go), runPing to the CLI dispatch
(main.go), and three tests covering plain-text echo, JSON output,
and missing-session-id validation (main_test.go). Default message
is "ping"; gateway echo is read from DiagnosticMessage, falling
back to the kind string if absent.
2026-06-15 09:41:40 -04:00
Joseph Doherty a211faefed feat(python): add galaxy-* CLI commands (§4.2) 2026-06-15 09:40:55 -04:00
Joseph Doherty 849f1d2f6d feat(go): add single-shot Write2 session helper (§4.1)
Add Write2/Write2Raw to the Go client Session, mirroring the existing
Write/WriteRaw pair, so all five language clients now expose write2.
Includes three TDD tests covering payload propagation, raw-reply return,
and nil-value rejection.
2026-06-15 09:40:15 -04:00
Joseph Doherty 883557fc8a docs: implementation plan for stillpending.md completion
28 tasks across 5 workstreams (A worker control cmds, B worker COM cmds,
C audit CorrelationId, D client CLI parity, E docs). Zero proto changes;
worker net48/x86 + Java on windev, rest local.
2026-06-15 09:35:50 -04:00
Joseph Doherty 4a00b1bdc1 docs: design for completing stillpending.md actionable items
Covers the 11 worker command kinds (§1.1), audit CorrelationId threading
(§1.2), client CLI/helper parity (§4), and doc hygiene (§7). Key finding:
all 11 commands already have proto/validation/scope/routing, so this is a
worker-executor + COM-wrapper + client-CLI effort with zero contract changes.
2026-06-15 09:32:01 -04:00
Joseph Doherty c7f754c77b fix(client/python): cap setuptools<77 so dist stays metadata 2.2 for Gitea PyPI feed; proprietary via classifier 2026-06-15 05:30:22 -04:00
Joseph Doherty 144c293f05 chore(clients): bump all five clients 0.1.0 -> 0.1.1 for release 2026-06-15 05:07:17 -04:00
Joseph Doherty 7c957908f8 docs(code-review): regenerate index — all re-review findings resolved 2026-06-15 03:04:37 -04:00
Joseph Doherty 6659653673 fix(client/java): handle PROVIDER_STATUS alarm-feed arm — CLI build break (Client.Java-039) 2026-06-15 03:01:13 -04:00
Joseph Doherty 75a39f5a8c fix(client/java): correct browseChildrenRaw README; CLI --require-certificate-validation (Client.Java-037,038) 2026-06-15 02:56:15 -04:00
Joseph Doherty cebe67e9bd fix(worker): resilient failover switch; FIPS-safe synthetic GUID; dup-reference guard + tests (Worker-026..028, Worker.Tests-031..033) 2026-06-15 02:56:15 -04:00
Joseph Doherty ddf2d84fbc contracts: round-trip degraded provenance/watch-list/mode-changed; proto doc (Contracts-018,019) 2026-06-15 02:46:06 -04:00
Joseph Doherty 56dd56954b test(gateway): cover failback reason, FromFeed/SinceUtc badge paths; style + bounded drain (Tests-032..035) 2026-06-15 02:46:06 -04:00
Joseph Doherty b57d02cc4d fix(client/rust): handle provider_status arm (build break); real system-roots TLS; design doc (Client.Rust-030..032) 2026-06-15 02:39:11 -04:00
Joseph Doherty 47062c1a6e fix(client/python): reachable cert-validation flag; bounded off-loop TOFU probe; license/marker fixes (Client.Python-027..031) 2026-06-15 02:39:11 -04:00
Joseph Doherty d0d1dcef15 fix(client/go): correct tag-go-module dirty-tree guard; GOPRIVATE docs (Client.Go-028,029) 2026-06-15 02:39:11 -04:00
Joseph Doherty fb2b1a4a52 fix(client/dotnet): restore warnings-as-errors floor; license metadata; LazyBrowseNode publication (Client.Dotnet-022..025) 2026-06-15 02:39:11 -04:00
Joseph Doherty d2c776901b fix(integrationtests): repair GatewayAlarmMonitor ctor build break; LDAP bind + docs (IntegrationTests-026..029) 2026-06-15 02:39:11 -04:00
Joseph Doherty 258e09e0de fix(server): propagate watch-list cancellation; doc + test gaps (Server-051..053) 2026-06-15 02:39:11 -04:00
Joseph Doherty 410acc92eb feat(dashboard): distinct 'forced' subtag provider badge
Render Fallback:Mode=ForceSubtag as a cyan 'Subtag monitoring (forced)'
badge, distinct from the amber failover 'degraded' badge, so an intentional
configuration isn't shown as a fault. Distinguished by the shared
AlarmProviderReasons.ForcedSubtag reason carried on the provider-status feed.
2026-06-15 01:43:17 -04:00
Joseph Doherty b40aaeef05 docs: forced-subtag fix resolution (#1 proto artifact, #2 fixed) 2026-06-15 01:33:14 -04:00
Joseph Doherty 9208225f9c fix: gateway reflects configured forced provider mode into gauge/feed (#2) 2026-06-15 01:10:04 -04:00
Joseph Doherty c6f17557f6 docs: forced-subtag mode fix plan 2026-06-15 01:04:46 -04:00
Joseph Doherty bbbef4d098 D2: document no current duplicate / endpoint (no-op) 2026-06-14 23:49:47 -04:00
Joseph Doherty 4af24b9518 D1: surface AlarmProviderSwitchCount on dashboard metric list 2026-06-14 23:49:02 -04:00
Joseph Doherty 371ce53409 docs: add deferred follow-ups plan (D1-D5) 2026-06-14 23:46:21 -04:00
Joseph Doherty 597677025f Merge alarm-fallback cleanup: metrics snapshot/reason, SQL prune, teardown, doc drift
Implements the actionable deferred items from pending.md (B1-B5, C6-C7):
- B1/B2 metrics: provider-switch count in snapshot + bounded reason enum
- B5: drop dead primitive branch from AlarmAttributesSql
- B3/B4 worker: UnAdvise only advised handles (+Dispose tests); remove dead field
- C6/C7: doc clarifications and design-doc superseded notes

Verified: gateway tests on macOS, net48/x86 worker suite (318 passed) on windev.
2026-06-14 02:39:10 -04:00
Joseph Doherty 393e326275 docs(alarms): note operator/IDE toggle drives the live subtag smoke test
C6a: the rig's TestAlarm attributes are object-driven; a flip script OR a manual
operator/IDE toggle drives them (confirmed live 2026-06-14). Update the how-to-run
comments and Skip reason accordingly.
2026-06-14 02:35:59 -04:00
Joseph Doherty 986dcee14a worker(alarms): UnAdvise only advised handles in LmxSubtagAlarmSource teardown
B3: track advised handles separately from added handles so Dispose only UnAdvises
items that were actually advised — a write-only subtag (e.g. ack-comment added by
Write, never advised) is removed but not unadvised. Add Dispose tests covering the
advised/write-only split, full removal, single Unregister, and double-dispose
idempotency.
2026-06-14 02:35:59 -04:00
Joseph Doherty a3752799de worker(alarms): remove dead FailoverAlarmConsumer.subscriptionExpression
B4: the field was stored in Subscribe but never read — the primary is never
re-subscribed during probing. Drop it and keep the rationale as a comment.
2026-06-14 02:35:59 -04:00
Joseph Doherty 37aadf72b3 docs(alarms): clarify resolver cancellation contract; mark design doc superseded
C6b: IAlarmWatchListResolver.ResolveAsync doc now notes that while discovery being
unavailable never throws, a triggered cancellation token still propagates.
C7: annotate the original design doc where it drifted from the shipped code — metric
names / unimplemented watch-list gauges, and the proto-type location (gateway proto, not
worker proto).
2026-06-14 02:33:14 -04:00
Joseph Doherty 5573f2a229 galaxy(alarms): drop dead primitive branch from AlarmAttributesSql
B5: the candidate CTE's src_pri=1 (primitive-instance) UNION ALL branch was always
excluded by the final WHERE r.src_pri=0, so it added work with no output change. Remove
the branch and the now-constant src_pri column/filter. An alarm anchor is always a user
attribute, so output is identical.
2026-06-14 02:33:14 -04:00
Joseph Doherty 56abd64c6c metrics(alarms): expose provider-switch count in snapshot, bound the reason tag
B1: add AlarmProviderSwitchCount to GatewayMetricsSnapshot so the switch total is
readable without scraping the OTEL counter.
B2: replace the free-text reason tag on mxgateway.alarms.provider_switches with a
bounded AlarmProviderSwitchReason enum (failover/failback/unknown); the human-readable
reason stays in the structured log.
2026-06-14 02:33:02 -04:00
Joseph Doherty 5b31e99ab6 alarms: compose subtag reference from object's real Galaxy area for exact alarmmgr parity 2026-06-14 02:12:11 -04:00
Joseph Doherty 64db828d71 docs(alarms): record live confirmation of subtag path + ack; advise-before-write requirement 2026-06-13 11:26:08 -04:00
Joseph Doherty 1a9367b5de worker(alarms): advise ack-comment subtag so the ack write targets an active MXAccess item 2026-06-13 11:23:39 -04:00
Joseph Doherty 98e997b573 test(alarms): probe writes evidence log to PROBE_LOG file 2026-06-13 11:15:05 -04:00
Joseph Doherty 0e8d911fd8 test(alarms): live runtime-path resolution probe (LiveMxAccessFact) for alarm subtags 2026-06-13 11:14:12 -04:00
Joseph Doherty e72763d703 alarms: use confirmed AVEVA AlarmExtension subtag names (InAlarm/Acked/AckMsg/Priority) 2026-06-13 11:07:22 -04:00
Joseph Doherty 3c9becc8d6 docs(plan): mark all alarm-subtag-fallback tasks completed 2026-06-13 10:55:18 -04:00
Joseph Doherty ec88532fe4 alarms: propagate degraded/source_provider through snapshot + gateway cache paths (integration fix I1/I2) 2026-06-13 10:53:55 -04:00
Joseph Doherty 2f30f0c7c0 docs(alarms): document alarmmgr->subtag fallback (providers, failover, config, contract, parity) 2026-06-13 10:43:37 -04:00
Joseph Doherty 27f6c9e6b7 dashboard(alarms): provider-status badge (alarmmgr vs degraded subtag) 2026-06-13 10:37:37 -04:00
Joseph Doherty 29bd504a99 test(alarms): end-to-end provider failover/failback lifecycle through GatewayAlarmMonitor 2026-06-13 10:34:24 -04:00
Joseph Doherty e10b252e3a test(alarms): drop unsupported Assert.Equal message args in live subtag smoke test (xUnit) 2026-06-13 10:30:39 -04:00
Joseph Doherty bcc54ca56b server(alarms): provider-mode gauge startup baseline; reconcile-lock comment; de-flake monitor test 2026-06-13 10:29:13 -04:00
Joseph Doherty ee459f43e1 test(alarms): opt-in live subtag-fallback smoke test (Skip by default)
Adds AlarmSubtagLiveSmokeTests to validate the open design item from Task 17:
confirms that LmxSubtagAlarmSource (real MxAccessComObjectFactory) wired to
SubtagAlarmConsumer synthesizes degraded Raise transitions with stable synthetic
GUIDs from Galaxy alarm subtags, and that AcknowledgeByName writes the
ack-comment subtag (rc=0). PLACEHOLDER_* subtag addresses are best-guess and
must be verified against MXAccess-Public-API.md + live Galaxy before flipping Skip.
2026-06-13 10:26:28 -04:00
Joseph Doherty ebf1d95f72 server(alarms): monitor resolves watch-list, sends ForcedMode/failover, reflects provider mode into feed + metrics 2026-06-13 10:20:03 -04:00
Joseph Doherty 3ccf0b5f9e server(alarms): honor ExcludeAttributes GR-only contract; warn on empty config-only watch-list 2026-06-13 10:12:58 -04:00
Joseph Doherty f7ccfd678e server(alarms): watch-list resolver merging GR discovery + config override 2026-06-13 10:09:10 -04:00
Joseph Doherty 3f5e5fc0b3 worker(alarms): route ForcedMode/watch-list/failover via AlarmCommandHandler; emit provider-mode-changed event 2026-06-13 10:04:33 -04:00
Joseph Doherty 7241a4fb9c worker(alarms): net48 index fix; enforce ProbeIntervalSeconds; OOM-safe catch; reset-on-failure test 2026-06-13 09:55:07 -04:00
Joseph Doherty d6c0bb41ca worker(alarms): failback probe re-polls the still-subscribed primary (no re-Subscribe) 2026-06-13 09:49:38 -04:00
Joseph Doherty 0a54c0bc4b worker(alarms): FailoverAlarmConsumer auto-failover/failback state machine 2026-06-13 09:46:47 -04:00
Joseph Doherty fd64b9260c worker(alarms): exact-match ack resolution (no substring false-match) + ack-by-guid tests 2026-06-13 09:42:00 -04:00
Joseph Doherty 4bd757a136 worker(alarms): SubtagAlarmConsumer synthesizing degraded transitions; dispatcher propagates Degraded 2026-06-13 09:35:49 -04:00
Joseph Doherty 1e2ed6d1ea worker(alarms): WriteRecord as class not positional record (net48 has no IsExternalInit) 2026-06-13 09:30:52 -04:00
Joseph Doherty 5f6655de27 server(alarms): drop redundant null-coalesce; tidy validator tests (review fixes) 2026-06-13 09:27:37 -04:00
Joseph Doherty fbc9cf56df worker(alarms): SyntheticAlarmGuid internal + alarmmgr-parity assertion (review fixes) 2026-06-13 09:26:52 -04:00
Joseph Doherty 4c0e14fc5d worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags 2026-06-13 09:24:09 -04:00
Joseph Doherty c75920c620 docs(plan): correct alarm proto location to mxaccess_gateway.proto (Tasks 1-2) 2026-06-13 09:18:11 -04:00
Joseph Doherty a46ce90e6f server(metrics): alarm provider mode gauge + provider switch counter (Task 13) 2026-06-13 09:18:11 -04:00
Joseph Doherty f113ca53a1 server(galaxy): GetAlarmAttributesAsync discovery query + alarm-attribute row mapping (Task 11) 2026-06-13 09:18:11 -04:00
Joseph Doherty f3616cc7fa server(alarms): AlarmFallbackOptions + ForceSubtag/threshold validation (Task 10) 2026-06-13 09:18:11 -04:00
Joseph Doherty 57d5a8725f worker(alarms): synthetic GUID + degraded/source_provider on emitted transitions 2026-06-13 09:14:23 -04:00
Joseph Doherty 60d35a914f contracts: regenerate Generated/ for alarm provider mode + subtag types
Keeps committed generated C# in sync with the .proto change in 1d85db7
(AlarmProviderMode, AlarmSubtagTarget, AlarmFailoverConfig, AlarmProviderStatus,
OnAlarmProviderModeChangedEvent, degraded/source_provider fields).
2026-06-13 09:10:08 -04:00
Joseph Doherty b10e103bcf worker(alarms): fix net48 build (init->set, usings), token-boundary name parse, acked latch, dup-address guard, tests 2026-06-13 09:05:58 -04:00
Joseph Doherty 348ab16456 worker(alarms): subtag value-source seam + synthesis state machine 2026-06-13 08:57:28 -04:00
Joseph Doherty c16f016f0a test(contracts): round-trip provider status + degraded provenance 2026-06-13 08:56:13 -04:00
Joseph Doherty 1d85db7b4e contracts(gateway): AlarmProviderMode, subtag watch-list, provider status, degraded provenance, mode-changed event 2026-06-13 08:53:02 -04:00
Joseph Doherty 5ea5618315 docs: implementation plan for alarm subtag-monitoring fallback
18 TDD tasks across contracts, worker (SubtagAlarmConsumer + FailoverAlarmConsumer),
gateway (GR-SQL watch-list discovery, monitor mode reflection, metrics, dashboard),
and docs. Grounded in current signatures; parity-preserving (worker-side synthesis).
2026-06-13 08:44:42 -04:00
Joseph Doherty 38a0ad8ab4 docs: design for alarmmgr→subtag alarm-provider fallback
Auto-failover/failback between the wnwrap alarmmgr consumer and a new
worker-side SubtagAlarmConsumer that advises alarm subtags and synthesizes
transitions. GR-SQL+config watch-list discovery, ack via ack-comment write,
degraded state surfaced in the gRPC contract and dashboard/metrics.
2026-06-13 08:35:18 -04:00
Joseph Doherty 5df2ef0d1e chore(theme): bump ZB.MOM.WW.Theme 0.3.0 -> 0.3.1 (interactive-render nav fix) 2026-06-05 07:19:11 -04:00
Joseph Doherty e5785fd769 chore(theme): consume ZB.MOM.WW.Theme 0.3.0 (nav/login kit fixes) 2026-06-05 05:13:06 -04:00
Joseph Doherty 22370ca4da docs(glauth): repoint glauth.md at the shared GLAuth on 10.100.0.35
No more per-box C:\publish\glauth NSSM service — dev/test LDAP is the shared
zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local). Provisioning now via
scadaproj/infra/glauth/config.toml. Old localhost/NSSM procedures kept as
retired reference; test users multi-role/gw-viewer.
2026-06-04 16:38:24 -04:00
Joseph Doherty e0a3fbf35b fix(dashboard)!: move login POST to /auth/login to resolve AmbiguousMatchException
The themed Blazor <LoginCard> page (Components/Pages/Login.razor, @page "/login")
registers a Razor Components endpoint that matches ALL HTTP methods. The credential
form POSTed to /login, where MapPost("/login") also matched — so every POST /login
threw Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException (HTTP 500),
breaking dashboard login for every user. It was latent because the dashboard was only
ever reached via the AllowAnonymousLocalhost bypass on the host box.

Move the credential POST to a distinct /auth/login route (mirroring ScadaBridge, which
never collided because it posts to /auth/login). GET /login stays the Blazor page; the
cookie LoginPath stays /login. Adds a registration assertion pinning DashboardLoginPost
to /auth/login as the regression guard.

Files: Login.razor (LoginCard Action), DashboardEndpointRouteBuilderExtensions (MapPost
route), GatewayApplicationTests (route assertion).
2026-06-04 14:01:05 -04:00
Joseph Doherty 161ed6f80d chore(theme): bump ZB.MOM.WW.Theme 0.2.0 -> 0.2.1 (desktop app-shell render fix) 2026-06-04 10:23:44 -04:00
Joseph Doherty e57d864ab2 fix(dashboard): make dashboard auth cookie name configurable
The dashboard auth cookie name was hardcoded to the constant
DashboardAuthenticationDefaults.CookieName (MxGatewayDashboard). Browser
cookies are scoped by host+path but NOT by port, so two gateway instances
sharing a hostname would clobber each other's dashboard session under the
shared name.

Add DashboardOptions.CookieName (MxGateway:Dashboard:CookieName); null/blank
keeps the canonical default. Applied in the existing dashboard cookie
PostConfigure (runs after the inline AddCookie default, so it wins). Behaviour
is unchanged when unset. Adds a Tests case for the override.
2026-06-03 13:11:29 -04:00
Joseph Doherty 5539ec8542 chore(dashboard): prune dead sidebar + orphaned login CSS from site.css
Removed the dead .sidebar nav block (replaced by the kit's .side-rail shell) and
the orphaned .dashboard-login/.login-card rules (the /login page now uses the
kit's <LoginCard>). Kept .app-bar (still used by the /denied page header) and the
.chip white-space override (emitted by StatusPill); corrected the now-stale
app-bar comment. 106 lines removed; builds clean.
2026-06-03 04:37:23 -04:00
Joseph Doherty 73e54e252d feat(dashboard): Blazor LoginCard page reusing the hardened /login endpoint 2026-06-03 03:56:51 -04:00
Joseph Doherty 70d959bd9b refactor(dashboard): StatusBadge delegates to ZB.MOM.WW.Theme StatusPill 2026-06-03 03:51:45 -04:00
Joseph Doherty 0c5b796e2e feat(dashboard): split MainLayout into ZB.MOM.WW.Theme ThemeShell + kit nav 2026-06-03 03:49:34 -04:00
Joseph Doherty 47dc9d865f refactor(dashboard): drop vendored theme.css/fonts/nav-state.js; keep app-only CSS in site.css
Repoint the server-rendered sign-in/fallback HTML (DashboardEndpointRouteBuilderExtensions) from /css/theme.css to the kit's _content/ZB.MOM.WW.Theme/css/{theme,layout}.css, mirroring ThemeHead, since that static page cannot use the Razor component.
2026-06-03 03:46:37 -04:00
Joseph Doherty 4f757e3c0c feat(dashboard): use ZB.MOM.WW.Theme ThemeHead + ThemeScripts 2026-06-03 03:44:18 -04:00
Joseph Doherty 2f0ee4c961 build(server): reference ZB.MOM.WW.Theme 0.2.0 2026-06-03 03:43:17 -04:00
Joseph Doherty 0859d47f75 feat(audit): MxGateway IAuditActorAccessor + dashboard audit Actor = operator principal (keyId→Target) (Phase 3)
Introduce IAuditActorAccessor seam + HttpAuditActorAccessor impl (reads ZbClaimTypes.Username
from IHttpContextAccessor; falls back to Identity.Name / ZbClaimTypes.Name; null when
unauthenticated). Register in DI via DashboardServiceCollectionExtensions.

Wire DashboardApiKeyManagementService: WriteDashboardAuditAsync now accepts the ClaimsPrincipal
user already in scope at each call site; ResolveOperatorActor extracts ZbClaimTypes.Username
(preferred) or Identity.Name. All four dashboard-* events now emit Actor = LDAP operator
username and Target = managed keyId, fixing the semantic gap where both fields held the keyId.

ConstraintEnforcer (gRPC / API-key actor) and CanonicalForwardingApiKeyAuditStore (CLI /
"system"/"cli" fallback) are unchanged.

Tests: DashboardApiKeyManagementServiceTests updated — CreateAuthorizedUser adds ZbClaimTypes.Username
("alice"), all dashboard-* audit assertions updated to Actor = "alice" / Target = "operator01";
new CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget verifies the
canonical AuditEvent directly. New HttpAuditActorAccessorTests (4 cases: username claim, Identity.Name
fallback, unauthenticated → null, no context → null). ConstraintEnforcer tests still assert API-key/anonymous actor.
2026-06-02 15:25:39 -04:00
Joseph Doherty 7ea8358c06 feat(audit): MxGateway local producers (dashboard + constraint-denial) emit canonical AuditEvent with Target/CorrelationId (Task 2.3 #6) 2026-06-02 10:13:54 -04:00
Joseph Doherty a5944bbe5d feat(audit): MxGateway canonical SQLite audit_event store + IAuditWriter + IApiKeyAuditStore->canonical adapter (Task 2.3) 2026-06-02 10:10:38 -04:00
Joseph Doherty 04bce3ff9f feat(auth)!: MxGateway canonical dashboard roles — Admin→Administrator (Task 1.7)
Standardize the dashboard role VALUE on the canonical six: Admin→Administrator
(Viewer unchanged). Pure value rename via DashboardRoles.Admin constant +
appsettings GroupToRole; the GatewayOptionsValidator allowed-set/message track
the constant so they now require 'Administrator' or 'Viewer'. Enforcement is
unchanged — Administrator authorizes exactly what Admin did.

Dashboard roles are derived at login from LDAP groups via GroupToRole and are
never persisted to the SQLite auth store, so no DB migration/seed change.

UNTOUCHED: the separate gRPC API-key scope GatewayScopes.Admin = "admin"
(lowercase) and every "admin" scope literal — a distinct data-plane system.
2026-06-02 07:22:42 -04:00
Joseph Doherty 9572045787 chore(auth): MxGateway unify dev LDAP base DN to dc=zb,dc=local (Task 1.6) 2026-06-02 06:44:38 -04:00
Joseph Doherty 7e1af37eb1 feat(auth): MxGateway dashboard adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
- DashboardAuthenticator.CreatePrincipal: emit ZbClaimTypes.Username ("zb:username") with
  the login username, ZbClaimTypes.DisplayName ("zb:displayname") with the display name,
  ZbClaimTypes.Name (== ClaimTypes.Name) for Identity.Name resolution, ZbClaimTypes.Role
  (== ClaimTypes.Role) for IsInRole/[Authorize]. Keep ClaimTypes.NameIdentifier for back-compat
  read-sites; keep mxgateway:ldap_group unchanged (MxGateway-specific, no ZbClaimType for groups).
  ClaimsIdentity built with nameType=ZbClaimTypes.Name, roleType=ZbClaimTypes.Role.
- DashboardServiceCollectionExtensions.AddGatewayDashboard: route cookie hardening through
  ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h); set cookie name/path/redirects
  after Apply; PostConfigure still overrides SecurePolicy per RequireHttpsCookie setting.
- DashboardAuthenticatorTests: add AuthenticateAsync_Success_EmitsCanonicalZbClaims asserting
  zb:username, zb:displayname, ZbClaimTypes.Role per role, Identity.Name, and ldap_group preserved.
2026-06-02 06:10:48 -04:00
Joseph Doherty 05009d7370 feat(auth): cut MxGateway API keys over to ZB.MOM.WW.Auth.ApiKeys 0.1.2; keep constraint enforcement+gRPC+CLI on top (Task 1.3) 2026-06-02 02:08:38 -04:00
Joseph Doherty f4dc11bae4 fix(auth): MxGateway 1.2 review fixes — group-claim doc, dedup LdapOptions, 0.1.1 pin 2026-06-02 01:28:57 -04:00
Joseph Doherty c3b466e13d feat(auth): cut MxGateway dashboard LDAP over to ZB.MOM.WW.Auth.Ldap; roles via IGroupRoleMapper (Task 1.2/1.4) 2026-06-02 00:51:10 -04:00
Joseph Doherty 792e3f9445 feat(auth): add IGroupRoleMapper<string> seam (Task 1.1) 2026-06-02 00:31:00 -04:00
Joseph Doherty ae281d06bb build: add ZB.MOM.WW.Auth/Audit feed mapping
Maps ZB.MOM.WW.Auth, ZB.MOM.WW.Auth.*, ZB.MOM.WW.Audit to the gitea feed.
PackageReferences (inline Version=) added during Phase 1/2 adoption.
2026-06-02 00:17:10 -04:00
Joseph Doherty 3ca2799c90 fix: tighten MxGateway Ldap:Port to 1-65535; catch IOException in path validation
Defect 1: ValidateLdap used AddIfNotPositive for Port, accepting any value
> 0 including 70000. Replaced with builder.Port() from the shared
ZB.MOM.WW.Configuration library, which enforces the 1-65535 TCP range and
emits "MxGateway:Ldap:Port must be between 1 and 65535 (was {value})".

Defect 2: AddIfInvalidPath only caught ArgumentException, NotSupportedException,
and PathTooLongException from Path.GetFullPath. On macOS/Linux a path containing
an embedded null throws IOException, which escaped the catch block and caused
Validate() to throw instead of returning a failure. Added catch (IOException).

Tests: added Validate_Fails_WhenLdapPortIsZero, Validate_Fails_WhenLdapPortExceedsMaximum,
and Validate_Succeeds_WhenLdapEnabledWithValidPort to cover the new range boundary.
2026-06-01 22:45:16 -04:00
Joseph Doherty 459a88b3e7 refactor: adopt ZB.MOM.WW.Configuration in MxGateway (behaviour-preserving) 2026-06-01 18:22:21 -04:00
Joseph Doherty 437ab65fc1 build: add ZB.MOM.WW.Configuration feed mapping + version pin 2026-06-01 18:10:27 -04:00
Joseph Doherty 679562e5ed Merge feat/telemetry-followons: telemetry follow-ons for MxAccessGateway
Metric normalization: meter MxGateway.Server -> ZB.MOM.WW.MxGateway and the 3
duration histograms ms -> s (safe: never Prometheus-exported before). Config-driven
OTLP exporter opt-in (default Prometheus). Metrics.md synced; doc-review artifacts
gitignored.
2026-06-01 17:17:31 -04:00
Joseph Doherty dbf550da8b docs(mxgateway): sync Metrics.md to renamed meter + seconds histogram units 2026-06-01 16:48:46 -04:00
Joseph Doherty 3965a7741e feat(mxgateway): config-driven OTLP exporter opt-in (default Prometheus) 2026-06-01 16:44:40 -04:00
Joseph Doherty abb2cfb84b feat(mxgateway): normalize metrics — meter ZB.MOM.WW.MxGateway + histograms in seconds 2026-06-01 16:39:56 -04:00
Joseph Doherty 4e0d8ccfed chore(mxgateway): gitignore CommentChecker doc-review artifacts 2026-06-01 16:34:46 -04:00
Joseph Doherty a935aa8b7c Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across MxAccessGateway
Full MEL->Serilog migration via AddZbSerilog; GatewayLogRedactor exposed through
the shared ILogRedactor seam; GatewayMetrics now exports via AddZbTelemetry + new
/metrics (meter name MxGateway.Server + ms histogram units unchanged; rename/unit
conversion deferred). Behaviour-preserving.
2026-06-01 16:05:41 -04:00
Joseph Doherty 9912389fa1 feat(mxgateway): export GatewayMetrics via AddZbTelemetry + /metrics (name/units unchanged) 2026-06-01 15:53:46 -04:00
Joseph Doherty f1129b969d feat(mxgateway): expose GatewayLogRedactor via shared ILogRedactor seam 2026-06-01 15:49:32 -04:00
Joseph Doherty c51b6f9ce4 feat(mxgateway): adopt AddZbSerilog — MEL→Serilog provider swap (behaviour-preserving) 2026-06-01 15:43:10 -04:00
Joseph Doherty e39972357b config(mxgateway): translate MEL Logging section to Serilog 2026-06-01 15:32:38 -04:00
Joseph Doherty 9ad17e2964 build(mxgateway): reference ZB.MOM.WW.Telemetry + Serilog packages 2026-06-01 15:29:43 -04:00
Joseph Doherty ef0a883a81 Merge feat/adopt-zb-health: ZB.MOM.WW.Health adoption + TLS auto-cert/lenient-client-trust feature 2026-06-01 14:09:24 -04:00
Joseph Doherty 62ba5e9487 feat: map canonical ZB health tiers; replace bypassing /health/live 2026-06-01 13:44:13 -04:00
Joseph Doherty 136614be94 feat: add AuthStoreHealthCheck readiness probe 2026-06-01 13:33:54 -04:00
Joseph Doherty a912bffad5 build: reference ZB.MOM.WW.Health from the Gitea feed 2026-06-01 13:29:39 -04:00
Joseph Doherty 9bdb899774 fix(clients): inline Go gosec directive and strip IPv6 brackets in Python authority split 2026-06-01 07:57:22 -04:00
Joseph Doherty e5c704de69 feat(gateway): add machine FQDN to self-signed cert SANs
Best-effort resolve the host FQDN via Dns.GetHostEntry and add it as a
DNS SAN when it differs (OrdinalIgnoreCase) from the short machine name
and "localhost". SocketException / ArgumentException are caught and
silently skipped so cert generation remains robust when DNS is absent.
2026-06-01 07:52:48 -04:00
Joseph Doherty 4e520f9c0c fix(gateway): delete temp cert file on persist failure
Wrap the WriteAllBytes/Move/HardenPermissions sequence in a try/catch so
that any failure best-effort deletes the hardened .tmp file (which may
already hold PFX/private-key bytes) before rethrowing.  Add a test that
induces a persist failure by pointing SelfSignedCertPath inside a
regular file and asserts no .tmp is left on disk.
2026-06-01 07:45:15 -04:00
Joseph Doherty 2eb81379e4 docs: TLS auto-cert and lenient client trust 2026-06-01 07:43:13 -04:00
Joseph Doherty ddd5721082 fix(gateway): harden self-signed cert persistence and config validation 2026-06-01 07:37:27 -04:00
Joseph Doherty 3775f6bf3b feat(gateway): supply generated cert as Kestrel HTTPS default 2026-06-01 07:30:26 -04:00
Joseph Doherty cdfad420bb fix(client-rust): apply TLS guard to GalaxyClient and add CLI strict flag
Extract the TLS-without-CA guard into a shared `build_tls_config` helper
in options.rs so both GatewayClient and GalaxyClient use identical logic.
GalaxyClient previously had no guard, so TLS-without-CA produced a cryptic
tonic handshake failure; it now returns the same actionable InvalidEndpoint
error. The guard message notes that a server-name override affects SNI but
does not pin trust. Add --require-certificate-validation to ConnectionArgs
in the CLI binary. Add a mirror test for GalaxyClient in tests/tls.rs.
2026-06-01 07:28:16 -04:00
Joseph Doherty 330e665f6b fix(gateway): correct ECDSA key usage and dispose CertificateRequest
Drop KeyEncipherment from the self-signed cert's key-usage extension — it
is semantically wrong for ECDSA (RSA key-transport only); DigitalSignature
alone is correct for TLS 1.3 / ECDHE server certs.  CertificateRequest is
unchanged (not IDisposable in .NET 10).  Test now also asserts MachineName,
127.0.0.1 and IPv6 loopback are present in the SAN extension.
2026-06-01 07:27:15 -04:00
Joseph Doherty 5e01ad9c22 fix(client-dotnet): apply lenient TLS to GalaxyRepositoryClient and enforce hostname on CA-pin
Mirror MxGatewayClient's three-branch handler structure in GalaxyRepositoryClient
(CA-pin / lenient accept-all / OS trust) so the Galaxy endpoint works against the
gateway's self-signed cert under the default lenient posture. Expose an internal
CreateHttpHandlerForTests seam for unit testing. Add RemoteCertificateNameMismatch
rejection at the top of both CA-pinned callbacks so a pinned-CA connection truly
verifies the host. Strengthen existing lenient test to invoke the callback and assert
it returns true; add mirrored Galaxy-client handler tests.
2026-06-01 07:24:07 -04:00
Joseph Doherty 77a9108673 feat(gateway): persist/reuse self-signed cert with hardened permissions 2026-06-01 07:23:33 -04:00
Joseph Doherty 192607ab8c fix(gateway): detect Certificate:Thumbprint and cover more KestrelTlsInspector cases 2026-06-01 07:22:24 -04:00
Joseph Doherty ba82afe669 fix(client-java): keep Temurin 21 toolchain, auto-provision instead of bumping to 26 2026-06-01 07:20:04 -04:00
Joseph Doherty fe7d1ce1ec feat(gateway): validate MxGateway:Tls options 2026-06-01 07:19:22 -04:00
Joseph Doherty b8a6695612 feat(gateway): generate self-signed ECDSA cert with SANs 2026-06-01 07:18:39 -04:00
Joseph Doherty 6f9188bc8d test(client-python): update TLS default-channel test for TOFU behavior 2026-06-01 07:17:36 -04:00
Joseph Doherty a276f46f81 feat(client-java): accept gateway cert by default over TLS 2026-06-01 07:13:45 -04:00
Joseph Doherty 572b268d81 feat(client-rust): accept gateway cert by default over TLS (or documented pin-only fallback) 2026-06-01 07:11:09 -04:00
Joseph Doherty 4c093a64fa feat(client-python): accept gateway cert by default via TOFU pre-fetch 2026-06-01 07:10:55 -04:00
Joseph Doherty f47bbaea95 feat(client-dotnet): accept gateway cert by default over TLS 2026-06-01 07:08:55 -04:00
Joseph Doherty c463b49f46 feat(client-go): accept gateway cert by default over TLS 2026-06-01 07:08:47 -04:00
Joseph Doherty 87f86503ef feat(gateway): add MxGateway:Tls options block 2026-06-01 07:08:19 -04:00
Joseph Doherty e912ef960c feat(gateway): detect HTTPS endpoints missing a certificate 2026-06-01 07:08:12 -04:00
Joseph Doherty c4e7ddea70 docs: implementation plan for gateway TLS auto-cert and lenient client trust 2026-06-01 07:01:58 -04:00
Joseph Doherty 6bfa4fe884 docs: design for gateway TLS auto-cert and lenient client trust 2026-06-01 06:54:23 -04:00
Joseph Doherty b4a7bac4c0 scripts: add pack-clients.ps1 to pack/publish all 5 client packages 2026-05-28 17:12:08 -04:00
Joseph Doherty 6df373ae4c client/go: release docs and tag-go-module.ps1 helper 2026-05-28 17:07:25 -04:00
Joseph Doherty fe44e3c18a client/java: maven-publish wiring for Gitea Maven feed 2026-05-28 17:07:11 -04:00
Joseph Doherty 523f944f3e client/rust: Cargo metadata + Gitea alternative-registry config 2026-05-28 17:06:47 -04:00
Joseph Doherty c33f1e6047 client/python: PyPI metadata + Gitea feed install instructions 2026-05-28 17:06:01 -04:00
Joseph Doherty 92cc4688e6 client/go: avoid holding mutex across BrowseChildren RPC in Expand 2026-05-28 15:33:48 -04:00
Joseph Doherty a155554038 grpc: reuse GalaxyBrowseProjector.ResolveParentId from handler 2026-05-28 15:32:48 -04:00
Joseph Doherty 68f905a344 client/java: avoid holding monitor across BrowseChildren RPC in expand 2026-05-28 15:32:36 -04:00
Joseph Doherty 5abc222c72 galaxy: add by-name and by-path indexes to GalaxyHierarchyIndex 2026-05-28 15:31:56 -04:00
Joseph Doherty da3aa7b0b2 client/go: paginate DiscoverHierarchy across multi-page galaxies 2026-05-28 15:31:16 -04:00
Joseph Doherty f0ec068430 galaxy: add cycle guard to HasMatchingDescendant 2026-05-28 15:30:08 -04:00
Joseph Doherty 1a1d14a9fd client/python: add public browse_children_raw for API parity 2026-05-28 15:29:08 -04:00
Joseph Doherty b2448510ac client/java: add browseChildrenRejectsRepeatedPageToken test for parity 2026-05-28 15:17:52 -04:00
Joseph Doherty 75610e3f55 client/go: wrap browseChildren duplicate-page-token error in GatewayError 2026-05-28 15:17:10 -04:00
Joseph Doherty 5032166106 client/dotnet: assert failed expand leaves node unexpanded 2026-05-28 15:16:07 -04:00
Joseph Doherty 76a042d663 grpc: make page_token error strings RPC-name-agnostic 2026-05-28 15:15:40 -04:00
Joseph Doherty 4a19854eb9 docs: per-client High-level walker example using LazyBrowseNode
Add a "High-level walker" subsection under each client's "Browsing
lazily" section showing idiomatic use of LazyBrowseNode (browse +
expand, idempotency note, redeploy refresh pattern).
2026-05-28 14:34:19 -04:00
Joseph Doherty a4467e23ef client/python: make LazyBrowseNode.expand concurrency-safe 2026-05-28 14:32:35 -04:00
Joseph Doherty eacfeff9fb client/dotnet: make LazyBrowseNode.ExpandAsync thread-safe 2026-05-28 14:28:36 -04:00
Joseph Doherty b4bc2df015 client/java: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:29:15 -04:00
Joseph Doherty fd2a0ac4c7 client/go: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:26:41 -04:00
Joseph Doherty 555e4be51f client/rust: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:26:05 -04:00
Joseph Doherty 1d8c0d83c4 client/python: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:24:23 -04:00
Joseph Doherty 6600f2a7bd client/dotnet: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:24:17 -04:00
Joseph Doherty 803a207ad2 client/java: regenerate protos for BrowseChildren
Regen'd from galaxy_repository.proto after BrowseChildren RPC was added.
GalaxyRepositoryGrpc and GalaxyRepositoryOuterClass now include the
BrowseChildrenRequest/BrowseChildrenReply types and stub methods.
2026-05-28 14:21:56 -04:00
Joseph Doherty 97e583e96b docs: implementation plan for per-language LazyBrowseNode walker
9 tasks: Java toolchain install (Homebrew), 5 parallel per-language
walker implementations, README updates, final verification. Java
walker is gated on toolchain bootstrap success; other languages
proceed independently if Java fails.
2026-05-28 14:17:52 -04:00
Joseph Doherty eaf479349d docs: design for client-side LazyBrowseNode walker + per-language tests
Adds one high-level walker per client (.NET/Python/Rust/Go/Java) plus
six unit tests each against existing fake transports. One-shot idempotent
Expand semantics; pagination hidden inside the helper. Includes Java
toolchain bootstrap (Homebrew Temurin + Gradle) so the Java client can
build locally on the macOS dev host.
2026-05-28 14:12:03 -04:00
Joseph Doherty 83a4d41fce docs: align design doc test-plan with InvalidArgument error mapping 2026-05-28 13:30:19 -04:00
Joseph Doherty 0d6193cdc4 docs: note BrowseChildren in gateway overview and client READMEs 2026-05-28 13:25:46 -04:00
Joseph Doherty 8cd3e1c20e client/go: regenerate protos for BrowseChildren 2026-05-28 13:22:06 -04:00
Joseph Doherty 5c28458624 client/rust: regenerate protos for BrowseChildren 2026-05-28 13:19:54 -04:00
Joseph Doherty 0b389f5a97 docs: document BrowseChildren RPC and lazy browse architecture 2026-05-28 13:19:08 -04:00
Joseph Doherty 108c4bb118 client/python: regenerate protos for BrowseChildren 2026-05-28 13:18:25 -04:00
Joseph Doherty cf54a278e1 docs: record lazy-browse stays wire-only; align error mapping 2026-05-28 13:18:23 -04:00
Joseph Doherty 81b2aacfe2 client/dotnet: live smoke for BrowseChildren 2026-05-28 13:17:29 -04:00
Joseph Doherty 5932fe2fd3 dashboard: surface lazy-load errors via BrowseLoadState.Error 2026-05-28 13:15:26 -04:00
Joseph Doherty 310dfab8b4 dashboard: lazy-load BrowsePage via DashboardBrowseService 2026-05-28 13:10:10 -04:00
Joseph Doherty ba157b4b4f grpc: implement BrowseChildren handler + metadata:read scope 2026-05-28 13:08:45 -04:00
Joseph Doherty 87e22dd529 galaxy: add GalaxyBrowseProjector for direct-children projection 2026-05-28 12:58:07 -04:00
Joseph Doherty d9eaf4b056 galaxy: add ChildrenByParent index for level-at-a-time browse 2026-05-28 12:51:48 -04:00
Joseph Doherty 2c5c5e5c7e contracts: add BrowseChildren RPC for lazy hierarchy browse 2026-05-28 12:47:02 -04:00
Joseph Doherty b3ebf583ad docs: implementation plan for lazy-browse BrowseChildren RPC
12-task bite-sized plan executing the approved design.
Includes native task persistence file.
2026-05-28 12:41:11 -04:00
Joseph Doherty edb812d859 docs: design for lazy-browse BrowseChildren RPC
OPC UA-style level-at-a-time browse across gRPC, dashboard, and the
shared cache projector. Server still loads the full Galaxy hierarchy;
laziness is wire-side and UI-side only.
2026-05-28 12:34:37 -04:00
Joseph Doherty 795eee72e3 client/dotnet: backfill XML doc comments to satisfy analyzers
Adds missing <summary>/<param> docs across the .NET client library and its
test suite so CommentChecker reports zero issues. TreatWarningsAsErrors
requires the analyzer surface clean before publishing the NuGet package.
2026-05-27 14:30:53 -04:00
Joseph Doherty 615b487a77 docs+ui: backfill XML doc comments and finish dashboard layout pass
Adds missing <summary>/<param> XML docs across 99 server, worker, and test
files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the
analyzer clean). Bundles in WIP dashboard work: NavSection extraction,
MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
2026-05-27 14:20:10 -04:00
Joseph Doherty 382861c602 build: add NonWindows.slnx for macOS/Linux dev hosts
The Worker + Worker.Tests projects pull in the Windows-only ArchestrA.MxAccess
COM stack and can't be built off Windows. Add a sibling .slnx that lists only
the cross-platform projects (Contracts, Server, IntegrationTests, Tests) so
non-Windows hosts can restore + build the rest of the solution with:

    dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx

The canonical solution on Windows remains ZB.MOM.WW.MxGateway.slnx.
2026-05-26 01:18:29 -04:00
Joseph Doherty ba2b936609 ui: align dashboard styling with ScadaLink master conventions
- Rename DashboardLayout.razor -> MainLayout.razor; dashboard.css -> site.css
- Sidebar 218 -> 220px; add hamburger + Bootstrap collapse for <lg viewports
- Rename .metric-* KPI classes to .agg-* (matches shared theme tokens)
- Rebuild ApiKeysPage create form as card + h6 subsections + bottom Save/Cancel
2026-05-26 01:12:54 -04:00
Joseph Doherty 7fc1955287 Dashboard: handle GET /logout (was 405) by signing out + redirecting to /login
Browsers that navigate directly to /logout via the address bar issued a GET
against a POST-only route and got 405 Method Not Allowed. Logout is
self-destructive, so the GET path can skip antiforgery; the existing POST
form (used by the layout's Sign out button) is unchanged and still
antiforgery-protected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:40:39 -04:00
Joseph Doherty 54480dde61 Add review-process + glauth design docs, bench scripts; ignore install/
Picks up the missing glauth.md referenced by CLAUDE.md, captures the
review workflow alongside the bench-read-bulk and review-readme helper
scripts, and excludes the local install/ deployment tree from source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:26:21 -04:00
Joseph Doherty 581b541801 code-reviews: regenerate after batch 2 resolutions
All 41 findings from the 42b0037 re-review are now Resolved across
8 modules. Open count = 0 for every module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:29:36 -04:00
Joseph Doherty d3cb311aae Resolve Client.Java-032..036: shared subscription base, batch tokenizer
Client.Java-032  README CLI examples for stream-alarms and
                 acknowledge-alarm now use the correct picocli flags
                 (--filter-prefix and --reference); two regression
                 tests parse each documented invocation.
Client.Java-033  StreamAlarmsCommand publishes an
                 AtomicReference<MxGatewayAlarmFeedSubscription> and
                 mirrors MxEventStream's overflow branch: a failed
                 queue.offer cancels the subscription, queues an
                 IllegalStateException, then queues the END sentinel
                 — preserving the fail-fast contract.
Client.Java-034  BatchCommand routes through a new
                 MxGatewayCli.tokenizeBatchLine POSIX-style shell
                 tokenizer that respects double-quoted, single-quoted,
                 and backslash-escaped arguments.
Client.Java-035  Added streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages
                 to MxGatewayClientSessionTests; asserts request shape,
                 message ordering, and cancellation propagation.
Client.Java-036  Extracted MxGatewayStreamSubscription<TRequest,TResponse>
                 abstract base; the four subscription classes
                 (MxGatewayEventSubscription, MxGatewayAlarmFeedSubscription,
                 MxGatewayActiveAlarmsSubscription, DeployEventSubscription)
                 collapse to ~10-line subclasses. A new contract test
                 runs identical lifecycle / cancellation assertions
                 across all four subclasses.

All resolved at 2026-05-24; gradle build + gradle test BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:29:27 -04:00
Joseph Doherty 186d03e5cc Resolve IntegrationTests-025: stopBoundary for repo-root walker
ResolveRepositoryRoot accepts an optional stopBoundary parameter that
caps the upward walk; production callers pass null and behavior is
unchanged. The two repository-marker tests now seal their walkers
inside their own temp directories, so a redirected TMP or a co-located
C:\src checkout no longer leaks ambient marker-bearing ancestors into
the assertion.

Regression test ResolveRepositoryRoot_StopBoundary_IsolatesWalkerFromAmbientAncestorMarkers
constructs an outer ancestor that carries src/ + .git, confirms the
walker leaks into it without the boundary, then asserts the same call
throws with the boundary supplied.

Resolved at 2026-05-24; IntegrationTestEnvironmentTests 5/5 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:29:09 -04:00
Joseph Doherty 6bae5ea3a3 Resolve Tests-027..031: flake root cause + coverage gaps
Tests-027  GatewayMetrics exposes its internal Meter; the
           StreamEvents_WhenEventIsWritten_RecordsSendDuration listener
           now filters by ReferenceEquals(instrument.Meter, metrics.Meter)
           instead of Meter.Name, so parallel tests with their own
           GatewayMetrics no longer cross-contaminate the families list.
Tests-028  FakeWorkerClient.Kill now captures LastKillReason;
           SessionManager.KillWorkerAsync tests pin the reason
           propagation end-to-end and cover the blank/null guard. The
           DashboardSessionAdminService kill test pins the literal
           dashboard-admin-kill reason.
Tests-029  Added CloseSessionAsync_BlankSessionId_ReturnsFailure to mirror
           the existing KillWorkerAsync blank-id coverage.
Tests-030  DeleteAsync_WhenStoreRefuses_ReportsFriendlyError renamed and
           extended to assert the dashboard-delete-key audit row with
           Details = not-found-or-active. Added
           DeleteAsync_BlankKeyId_ReturnsFailure.
Tests-031  DashboardSnapshotPublisher reconnect test now measures the
           gap from the first throw inside the fake (firstThrowAt) to
           secondSubscribeAt, isolating Task.Delay from StartAsync /
           scheduling overhead.

All resolved at 2026-05-24; 512/512 gateway tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:28:54 -04:00
Joseph Doherty 430187c28b code-reviews: regenerate after batch 1 resolutions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:39 -04:00
Joseph Doherty f5b50c4484 Resolve Client.Python-022..026: TLS-by-default, batch CLI, README
Client.Python-022  README CLI examples for stream-alarms and
                   acknowledge-alarm now use the correct flags;
                   regression test parses every documented line through
                   Click.
Client.Python-023  Re-applied Client.Python-013 — _use_plaintext drops
                   the silent localhost / 127.0.0.1 auto-downgrade
                   branch; --plaintext and --tls are mutually exclusive
                   and TLS is the default.
Client.Python-024  batch dispatch routes through main.main(...,
                   standalone_mode=False) under a redirected stdout
                   instead of click.testing.CliRunner; recursive batch
                   lines are rejected outright.
Client.Python-025  Added behavioural tests for the five bulk SDK methods,
                   stream_alarms, and the new CLI subcommands.
Client.Python-026  _bench_read_bulk hoists 'import time' to module scope
                   and logs cleanup failures instead of swallowing them.

All resolved at 2026-05-24; python -m pytest is 65/65 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:27 -04:00
Joseph Doherty 4a0f88b17d Resolve Client.Rust-022..029: MalformedReply, correlation ids, clippy
Client.Rust-022  Restored Error::MalformedReply for register / add_item /
                 add_item2 and the bulk-subscribe / read-bulk / write-bulk
                 dispatch arms so malformed-but-OK replies fail loudly
                 instead of returning Vec::new().
Client.Rust-023  Restored next_correlation_id and routed every CLI close /
                 stream-alarms / acknowledge-alarm / bench-read-bulk call
                 through it so each call carries a unique opaque token.
Client.Rust-024  Added round-trip tests for read_bulk / write_bulk /
                 write2_bulk / write_secured_bulk / write_secured2_bulk
                 plus stream_alarms and percentile_summary unit tests.
Client.Rust-025  RustClientDesign.md re-synced — new bulk SDK, alarms
                 surface, Error variants, CLI command list, and the
                 Windows stack workaround.
Client.Rust-026  Session::read_bulk now borrows a tag slice; bench-read-
                 bulk binds tags once outside the warm-up / steady-state
                 loops.
Client.Rust-027  .cargo/config.toml selector tightened to
                 cfg(all(windows, target_env = "msvc")) and comment
                 rewritten to match reality (release + debug ship the
                 8 MB reservation).
Client.Rust-028  run_batch removed the empty-line break; stdin EOF is
                 the only terminator.
Client.Rust-029  Re-applied Client.Rust-001 / 002 / 012 — added the
                 missing doc comments, renamed BulkReplyKind variants,
                 and replaced the clone-on-copy with a deref under lock
                 so cargo clippy -D warnings is clean.

All resolved at 2026-05-24; cargo fmt + check + clippy + test all green
(55 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:15 -04:00
Joseph Doherty 82996aa8e6 Resolve Client.Go-022..027: bulk flags, bench cancel, batch loop
Client.Go-022  Re-applied Client.Go-015 shape — runWriteBulkVariant drops
               the unused secured param and gates -current-user-id /
               -verifier-user-id / -user-id behind the secured-only
               variants.
Client.Go-023  Re-applied Client.Go-018 shape — bench warm-up and steady-
               state loops respect ctx.Err().
Client.Go-024  Added SDK-level tests for WriteBulk / Write2Bulk /
               WriteSecuredBulk / WriteSecured2Bulk / ReadBulk and
               StreamAlarms via the existing bufconn fake gateway pattern.
Client.Go-025  Five bulk SDK methods short-circuit on empty input without
               an RPC round-trip and document the behavior.
Client.Go-026  runBatch widens scanner.Buffer to 16 MiB and emits an
               error-with-sentinel if a longer line still arrives, rather
               than aborting the session silently.
Client.Go-027  runBatch treats blank lines as skip-and-continue; only EOF
               ends the session.

All resolved at 2026-05-24; gofmt + go vet + go build + go test ./... all
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:58 -04:00
Joseph Doherty 712cb06442 Resolve Client.Dotnet-018..021: README + bench-read-bulk hardening
Client.Dotnet-018  README CLI examples for stream-alarms / acknowledge-alarm
                   replaced with parser-correct flags; new theory test
                   parses each documented README example through the CLI.
Client.Dotnet-019  BenchReadBulkAsync routes through new
                   RequireRegisterServerHandle helper that fails loudly when
                   the OK register reply has no typed payload.
Client.Dotnet-020  Bench steady-state catch is now
                   catch (Exception ex) when (ex is not OperationCanceledException)
                   so user-driven cancellation exits promptly.
Client.Dotnet-021  --timeout-ms now flows through ParseTimeoutMs which
                   rejects negatives with a clear error in both read-bulk
                   and bench-read-bulk.

All resolved at 2026-05-24; 67/67 .NET client tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:45 -04:00
Joseph Doherty 4d77279e7e Resolve Server-044..050: KillWorker accounting + admin service hardening
Server-044  KillWorkerAsync catch path now calls _metrics.SessionRemoved
            so the open-session gauge does not leak when KillWorker throws.
Server-045  KillWorkerAsync routes through a new
            GatewaySession.KillWorkerWithCloseGateAsync that takes the
            per-session close lock, so concurrent kills count SessionsClosed
            exactly once.
Server-046  CloseSessionCoreAsync's SessionCloseStartedException branch and
            ShutdownAsync's kill fallback both increment SessionsClosed (not
            just the gauge), so the counter and gauge stay consistent.
Server-047  ApiKeysPage.ConfirmPendingAsync holds PendingAction across the
            awaited action and clears it in finally, matching the sessions
            pages.
Server-048  Closed: the 044/045 regression tests cover the previously-
            untested kill paths.
Server-049  IDashboardSessionAdminService + DashboardSessionAdminService
            now carry XML docs that pin the Admin gate, missing-session
            return-Fail semantics, and the dashboard-admin-kill reason.
Server-050  CloseSessionAsync and KillWorkerAsync catch unexpected
            exceptions after the SessionManagerException catches and return
            a friendly Fail; OperationCanceledException tied to the caller
            token still propagates.

All resolved at 2026-05-24; 503/503 gateway tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:34 -04:00
Joseph Doherty 6079c62709 code-reviews: regenerate index at 42b0037
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:56 -04:00
Joseph Doherty 37ef27e8ed code-reviews: bump Worker + Worker.Tests headers to 42b0037
No source changes since d692232 — header bumped to track the latest
reviewed commit, all prior findings remain closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:56 -04:00
Joseph Doherty db2218f395 code-reviews: re-review Client.Java at 42b0037
Append 5 new findings (Client.Java-032..036): README flags for new
alarm subcommands do not exist; StreamAlarmsCommand bounded queue
silently drops alarms instead of fail-fast; BatchCommand whitespace
tokenisation shreds quoted args; no library-side stream_alarms test;
MxGatewayAlarmFeedSubscription is the fourth duplicate subscription
class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:55 -04:00
Joseph Doherty bc28fee641 code-reviews: re-review Client.Python at 42b0037
Append 5 new findings (Client.Python-022..026): README flags for new
alarm subcommands do not exist; Client.Python-013 regression — the
silent localhost auto-plaintext branch is still present (the prior
Resolution did not survive the rename); production batch path uses
the click.testing.CliRunner helper; no behavioural tests for new SDK
+ CLI; bench cleanup swallows exceptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:55 -04:00
Joseph Doherty 15fceed536 code-reviews: re-review Client.Rust at 42b0037
Append 8 new findings (Client.Rust-022..029): MalformedReply path
absent from the new bulk SDK methods, hard-coded client_correlation_id
in new CLI commands, no tests for stream_alarms / bulk SDK / bench,
RustClientDesign.md silent on new surface, run_bench_read_bulk clones
tags inside the loop, .cargo/config.toml comment is wrong, run_batch
exits on blank line, and cargo clippy fails at HEAD with three
warnings the prior reviewer punted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:41 -04:00
Joseph Doherty afa82e0989 code-reviews: re-review Client.Go at 42b0037
Append 6 new findings (Client.Go-022..027): regressions of Client.Go-015
(runWriteBulkVariant secured flag) and Client.Go-018 (bench loop
ignores ctx.Err()) reintroduced by the bulk port; no SDK tests for the
new WriteBulk/ReadBulk/StreamAlarms methods; bulk SDK accepts empty
slices; bufio.Scanner buffer + blank-line sentinel can abort the batch
session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:41 -04:00
Joseph Doherty b9ef09d26e code-reviews: re-review Client.Dotnet at 42b0037
Append 4 new findings (Client.Dotnet-018..021): README flags for the
new stream-alarms/acknowledge-alarm subcommands cite options that do
not exist on the CLI; BenchReadBulkAsync reinstates the silent
register-handle fallback and swallows OperationCanceledException;
both new --timeout-ms consumers cast int32 to uint without bounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:40 -04:00
Joseph Doherty 7d66967122 code-reviews: re-review IntegrationTests at 42b0037
Append 1 new finding (IntegrationTests-025): the
ResolveRepositoryRoot_NoMarkers test walks up from Path.GetTempPath()
through unisolated ancestors; a redirected TMP or co-located checkout
at C:\src silently breaks the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:26 -04:00
Joseph Doherty 2f8404d2ef code-reviews: re-review Contracts at 42b0037
No new findings — the only Contracts commit in window (bd1d1f1) is a
comment-only proto edit; field numbering remains additive with no
reuse, renumbering, or type narrowing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:25 -04:00
Joseph Doherty 2b92be02b9 code-reviews: re-review Tests at 42b0037
Append 5 new findings (Tests-027..031) covering the
StreamEvents_WhenEventIsWritten_RecordsSendDuration flake root cause
(shared MeterListener by meter name), missing kill-path coverage
(reason propagation + concurrent-kill double-count), asymmetric guard
coverage between Close and Kill, missing audit-failure-path coverage
for ApiKey Delete, and the DashboardSnapshotPublisher reconnect-window
timer sensitivity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:15 -04:00
Joseph Doherty 056f0d8808 code-reviews: re-review Server at 42b0037
Append 7 new findings (Server-044..050) covering the destructive-action
wave: KillWorkerAsync metric/state leaks, ShutdownAsync kill-fallback
gauge leak, inconsistent ConfirmDialog cleanup across pages, missing
XML docs on the new DashboardSessionAdmin surface, and unhandled
RemoveSessionAsync exception paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:15 -04:00
Joseph Doherty 42b0037376 Dashboard: replace inline fully-qualified type refs with @using
ApiKeysPage and GalaxyPage referenced ApiKeyConstraints and
GalaxyRepositoryOptions by full namespace inside @code. Add the
appropriate @using directive at the top of each file and let the
short type names resolve through that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:51:00 -04:00
Joseph Doherty de7639a3e9 Dashboard: fix 500 on / from duplicate endpoint mapping
GatewayApplication.MapGatewayEndpoints registered MapGet("/", ...)
that redirected to /health/live. After the dashboard added a Blazor
component at @page "/", both endpoints matched GET / and the matcher
threw AmbiguousMatchException, surfacing as a 500. The redirect
predated the dashboard home page — drop it so / lands on Overview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:48:43 -04:00
Joseph Doherty 8738735f0d clients: document StreamAlarms + AcknowledgeAlarm in each README
Each client's README now covers the alarms surface in both the SDK
section (StreamAlarms / AcknowledgeAlarm beside the existing
QueryActiveAlarms entry, with the streaming-cancellation note) and
the CLI examples (stream-alarms / acknowledge-alarm invocations
mirroring the in-tree implementations across .NET, Go, Rust, Python,
and Java).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:40:23 -04:00
Joseph Doherty e80f3c70b6 docs: cover admin dashboard actions + API key Delete
Update the design docs so they match the implemented Admin-only
dashboard surface. GatewayDashboardDesign now documents the Close
session / Kill worker controls and the new Delete action on revoked
API keys, plus the ConfirmDialog gate for every destructive action.
Sessions.md adds the SessionManager.KillWorkerAsync entry alongside
CloseSessionAsync and explains the immediate-kill semantics. Authentication.md adds the IApiKeyAdminStore.DeleteAsync write path
and the dashboard-delete-key audit event. DashboardInterfaceDesign
drops the "read-only until admin workflows have a separate design"
line in favor of the confirm-before-act invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:35:25 -04:00
Joseph Doherty 24cc5fd0f0 Dashboard: delete revoked API keys + confirm Rotate/Revoke/Delete
Add IApiKeyAdminStore.DeleteAsync that only deletes already-revoked
rows (active keys must be revoked first so the revoke event lands in
the audit log before the row disappears) and a matching admin-gated
DashboardApiKeyManagementService.DeleteAsync. ApiKeysPage now shows
Delete on revoked rows in place of the old "No actions" stub, and
Rotate/Revoke/Delete all route through ConfirmDialog so each
destructive action requires an explicit confirmation step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:30:30 -04:00
Joseph Doherty c5153d68bb Dashboard: fix API keys page stuck on "Loading"
ApiKeysPage.OnInitializedAsync overrode the base without chaining, so
DashboardPageBase's Snapshot seed and hub connect never ran and the
page rendered the null-snapshot empty state forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:22:38 -04:00
Joseph Doherty 0e56b5befb Dashboard: confirm before Close session / Kill worker
Add a shared ConfirmDialog component and route Sessions, Workers, and
SessionDetails Close/Kill buttons through it. The dialog shows the
target session id and a color-matched confirm button (yellow Close,
red Kill); Cancel dismisses without invoking the admin service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:17:32 -04:00
Joseph Doherty c5e7479ee4 Dashboard: admin-only Close session / Kill worker
Add IDashboardSessionAdminService (Admin-role gate, friendly errors,
audit logging) wrapping a new ISessionManager.KillWorkerAsync that
skips graceful shutdown and cleans up registry/metrics. Sessions,
Workers, and SessionDetails pages render Close / Kill buttons only
when CanManage; the service re-checks the role on every call so
forged clicks return Unauthenticated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:10:32 -04:00
Joseph Doherty 8a0c59d7e8 Java client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to the Java CLI. stream-alarms
attaches to the gateway's central alarm feed (--filter-prefix, --limit, --json
— NDJSON, one AlarmFeedMessage per line); acknowledge-alarm is a unary ack
(--reference required, --comment, --operator). streamAlarms joins
queryActiveAlarms on MxGatewayClient and uses a new
MxGatewayAlarmFeedSubscription cancellable handle. Batch dispatch re-enters the
picocli command line per stdin line, so registering the two new subcommands
suffices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:46:03 -04:00
Joseph Doherty 828e3e6cf6 Python client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to mxgw-py. stream-alarms reads a
bounded slice of the gateway's central alarm feed (--filter-prefix,
--max-messages, --timeout, --json; aggregate `{messages: [...]}`);
acknowledge-alarm is a unary ack (--reference required, --comment, --operator).
GatewayClient.stream_alarms joins query_active_alarms via a
_canceling_alarm_feed_iterator helper mirroring the existing
_canceling_active_alarms_iterator pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:54 -04:00
Joseph Doherty 7de4efeb02 Rust client: port stream-alarms and acknowledge-alarm + fix stream-events family + 8MB Windows stack
Adds the session-less alarm CLI subcommands to mxgw. stream-alarms attaches to
the gateway's central alarm feed (--filter-prefix, --max-events, --json/--jsonl;
aggregate shape `{messageCount, messages: [...]}`); acknowledge-alarm is a unary
ack (--reference required, --comment, --operator). stream_alarms joins
query_active_alarms on GatewayClient and re-exports AlarmFeedStream.

Also extends stream-events JSON to emit a full `events` array (itemHandle, value
projected to protojson-shaped `*Value` keys, etc.) instead of just `eventCount`,
matching the other four CLIs, and renders MxEvent.family as the protobuf enum
NAME (MX_EVENT_FAMILY_ON_WRITE_COMPLETE) rather than the raw i32 so the e2e
write round-trip can recognise the OnWriteComplete echo.

Adds clients/rust/.cargo/config.toml bumping the Windows main-thread stack to
8 MB via /STACK:8388608. clap-derive's Command enum (one variant per subcommand)
overflowed the default 1 MB stack in debug builds after the new variants
landed; release builds were unaffected but the e2e matrix runs Rust via
`cargo run` (debug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:46 -04:00
Joseph Doherty 6f0d142639 Go client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to mxgw-go. stream-alarms attaches
to the gateway's central alarm feed (--filter-prefix, --limit, --json — NDJSON,
one AlarmFeedMessage per line); acknowledge-alarm is a unary ack (--reference
required, --comment, --operator). StreamAlarms joins QueryActiveAlarms on the
public Client and is wired through the existing batch dispatcher via runWithIO.
SDK type aliases for StreamAlarmsRequest / AlarmFeedMessage / StreamAlarmsClient
land alongside the existing alarm types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:32 -04:00
Joseph Doherty 11cc6715ed .NET client: port stream-alarms and acknowledge-alarm + fix stream-events OCE
Adds the session-less alarm CLI subcommands. stream-alarms attaches to the
gateway's central alarm feed (--filter-prefix, --max-events, --json/--jsonl);
acknowledge-alarm is a unary ack (--reference required, --comment, --operator).
StreamAlarmsAsync joins QueryActiveAlarmsAsync on MxGatewayClient and the
transport interface; the CLI client interface, adapter, and FakeGatewayTransport
follow.

Also fixes the OCE bug exposed by -VerifyWrite in the cross-language e2e:
StreamEventsAsync's await foreach now swallows OperationCanceledException when
the supplied cancellation token is the one that fired (graceful end-of-window),
and RunBatchAsync no longer excludes OCE from its outer catch — so a streaming
command that hits its --timeout reports a JSON error inside its EOR-delimited
record instead of killing the long-lived batch process.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:24 -04:00
Joseph Doherty f90bff01db Java client: port bulk read/write SDK methods + CLI subcommands
Final language in the bulk-CLI port wave. HEAD's MxGatewaySession had
only the subscribe-style bulks; this commit adds the value-bulks plus
matching picocli subcommands and a bench-read-bulk harness.

SDK (MxGatewaySession.java):
- List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries)
- List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries)
- List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries)
- List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries)
- List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout)

readBulk uses java.time.Duration for the timeout parameter (idiomatic
Java) and internally converts to the timeoutMs proto field;
Duration.ZERO / null both delegate to the worker default. Per-entry
secured user ids stay on each WriteSecured(2)BulkEntry to match the
proto's per-row shape.

CLI (MxGatewayCli.java):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk as picocli @Command subcommands. Write families
  share value-parsing logic; gating of --current-user-id /
  --verifier-user-id / --timestamp matches the cross-language flag
  contract.
- bench-read-bulk: --iterations / --warmup loop with avg/min/max ms
  reporting plus a --json mode that emits the cross-language bench
  JSON schema.

A small fixture in MxGatewayCliTests.FakeSession adds stub
implementations of the five new interface methods so the test module
compiles.

Verification: gradle build BUILD SUCCESSFUL (4 tasks executed, all
tests pass); gradle :zb-mom-ww-mxgateway-cli:installDist BUILD
SUCCESSFUL. Manual smoke against live gateway on localhost:5120:
open-session → register → read-bulk cold (wasCached=false both tags)
→ subscribe-bulk → read-bulk warm (wasCached=true both tags) →
write-bulk int32 111,222 (both wasSuccessful=true) → write2-bulk
timestamped (both wasSuccessful=true) → write-secured-bulk and
write-secured2-bulk return per-entry MXAccess "Value does not fall
within the expected range" failures with the configured user/verifier
ids (0,0) — confirming the SDK does NOT throw on per-entry MXAccess
failures and surfaces them through BulkWriteResult exactly as the
.NET and Go ports do → bench-read-bulk iterations=20 avg=9.5 ms
last_success=2/2 cached=2/2 → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:34 -04:00
Joseph Doherty 6add4b4acc Python client: port bulk read/write SDK methods + CLI subcommands
Mirrors the .NET / Go ports of divergent branch commit f220908. HEAD's
Session class had only the subscribe-style bulks; this commit adds the
value-bulk SDK surface plus matching CLI subcommands and a
bench-read-bulk harness.

SDK (zb_mom_ww_mxgateway/session.py):
- async def write_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def read_bulk(server_handle, tag_addresses, *, timeout_ms=0,
  correlation_id="") → list[pb.BulkReadResult]

All five reuse the existing _ensure_bulk_size validator and route
through the existing invoke() pipeline. read_bulk additionally enforces
timeout_ms >= 0.

CLI (zb_mom_ww_mxgateway_cli/commands.py):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk registered as click @main.command(...). The
  write families share a _build_write_bulk_entries() helper that parses
  --item-handles and --values with a single --type, validates count
  match, converts via to_mx_value, and assembles the correct per-entry
  proto message.
- bench-read-bulk: opens its own session, subscribes to --bulk-size
  TestMachine_NNN.TestChangingInt tags, runs warmup then steady-state
  ReadBulk for --duration-seconds with time.perf_counter() latency
  capture, and emits the shared JSON schema (language, durationMs,
  totalCalls, successfulCalls, failedCalls, totalReadResults,
  cachedReadResults, callsPerSecond, latencyMs:{p50,p95,p99,max,mean})
  so scripts/bench-read-bulk.ps1 collates Python alongside the four
  other clients. _percentile_summary + linear-interpolation
  _percentile helper match the Go / .NET implementations.

to_mx_value is added to the existing values-module import line in
commands.py since the bulk-write commands need it.

Verification: python -m pip install -e . --quiet --no-deps; pytest
42/42 passing. Manual smoke against live gateway on localhost:5120:
open-session → register → subscribe-bulk on two
TestMachine_NNN.TestChangingInt tags (both wasSuccessful=true) →
read-bulk (both wasSuccessful=true / wasCached=true / int32 values
present) → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:10 -04:00
Joseph Doherty 325106920f Rust client: port BenchReadBulk subcommand + session.rs tightening
The bulk-write/read SDK methods (read_bulk, write_bulk, write2_bulk,
write_secured_bulk, write_secured2_bulk) and the matching clap
subcommands (ReadBulk, WriteBulk, Write2Bulk, WriteSecuredBulk,
WriteSecured2Bulk) were already on HEAD from a prior session — they
were the only bulk family that HEAD shipped before the .NET / Go /
Python / Java parallel ports. The one missing piece from the divergent
branch (commit f220908) was the BenchReadBulk benchmark harness.

mxgw-cli/src/main.rs adds:
- BenchReadBulk clap variant with flags --client-name,
  --duration-seconds, --warmup-seconds, --bulk-size, --tag-start,
  --tag-prefix, --tag-attribute, --timeout-ms, --json — defaults match
  the .NET and Go benches.
- run_bench_read_bulk(): open-session → register → subscribe_bulk on
  the synthesized TestMachine_NNN.TestChangingInt tags to populate the
  worker value cache → warmup → steady-state loop with per-call
  std::time::Instant capture → unsubscribe → close-session.
- BenchStats + LatencySummary structs and a percentile()
  helper (nearest-rank with linear interpolation, matching the Go and
  .NET implementations) so the cross-language JSON output is byte-for-
  byte comparable. JSON schema: language / command / endpoint /
  clientName / bulkSize / durationSeconds / warmupSeconds / durationMs
  / tags / totalCalls / successfulCalls / failedCalls /
  totalReadResults / cachedReadResults / callsPerSecond /
  latencyMs:{p50,p95,p99,max,mean}. scripts/bench-read-bulk.ps1 will
  pick up the Rust line on its next run.

session.rs picks up minor tightening tied to the bulk SDK methods that
were already in the file (per-entry validation paths, BulkReplyKind
dispatch coverage) — no public-surface change.

Verification: cargo build --workspace clean (the 2 pre-existing
options.rs missing_docs warnings remain — out of scope); cargo test
--workspace 34/34 passing; cargo clippy --workspace --all-targets has
only the 3 pre-existing tolerated warnings (enum_variant_names on
BulkReplyKind, missing_docs on options.rs, clone_on_copy on
galaxy.rs:282). Manual smoke against live gateway on localhost:5120:
read-bulk on two TestMachine tags returned wasCached=true,
wasSuccessful=true; bench-read-bulk --duration-seconds 2
--warmup-seconds 1 --bulk-size 2 --json ran 363 calls / 181.35 calls
per second / p50=5.3 ms / p99=7.8 ms / 726 of 726 cached reads, all
emitting valid JSON in the shared bench schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:09 -04:00
Joseph Doherty 8aaab82287 Go client: port bulk read/write SDK methods + CLI subcommands
Mirrors the .NET addition: HEAD's session.go had only the subscribe-style
bulks (AddItemBulk / AdviseItemBulk / RemoveItemBulk / UnAdviseItemBulk /
SubscribeBulk / UnsubscribeBulk). This commit ports the value-bulk SDK
surface and CLI subcommands from divergent branch commit f220908.

SDK (clients/go/mxgateway/session.go):
- WriteBulk(ctx, serverHandle int32, entries []*WriteBulkEntry)
- Write2Bulk(ctx, ..., entries []*Write2BulkEntry)
- WriteSecuredBulk(ctx, ..., entries []*WriteSecuredBulkEntry)
- WriteSecured2Bulk(ctx, ..., entries []*WriteSecured2BulkEntry)
- ReadBulk(ctx, serverHandle int32, tagAddresses []string, timeout time.Duration)
  → []*BulkReadResult

types.go gains public re-exports of the generated proto types
(WriteBulkCommand, WriteBulkEntry, Write2BulkCommand, Write2BulkEntry,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecured2BulkCommand,
WriteSecured2BulkEntry, ReadBulkCommand, BulkWriteReply, BulkWriteResult,
BulkReadReply, BulkReadResult) so external callers can construct entries
through the public `mxgateway` package without dipping into the internal
generated path.

CLI (clients/go/cmd/mxgw-go/main.go):
- read-bulk, write-bulk, write2-bulk, write-secured-bulk,
  write-secured2-bulk routed through runWithIO. write families share a
  runWriteBulkVariant helper that gates per-variant flags
  (--current-user-id, --verifier-user-id, --timestamp) so the
  Client.Go-015 flag-gating contract is preserved.
- bench-read-bulk: percentile + timing helpers; JSON output schema
  identical to the .NET / Rust / Python / Java benches.

parseInt32List was changed from panic-on-error to ([]int32, error) so
the new write-bulk commands surface parse errors gracefully; the
existing runUnsubscribeBulk caller is updated accordingly.

Verification: go build ./... + go vet ./... + go test ./... all clean.
Manual smoke against live gateway on localhost:5120: open-session →
register → subscribe-bulk on 3 TestMachine_NNN.TestChangingInt tags
(all wasSuccessful=true) → read-bulk (all wasSuccessful=true /
wasCached=true) → write-bulk int32 100/200/300 (all wasSuccessful=true)
→ close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:49:33 -04:00
Joseph Doherty b3ae200b11 .NET client: port bulk read/write SDK methods + CLI subcommands
Adds the value-bulk SDK surface and CLI subcommands that lived on the
divergent branch (commit f220908) but were never merged into main.
HEAD's MxGatewaySession only had the subscribe-style bulks (AddItem /
Advise / Remove / UnAdvise / Subscribe / Unsubscribe). The proto
contract already defined ReadBulkCommand / WriteBulkCommand /
Write2BulkCommand / WriteSecuredBulkCommand / WriteSecured2BulkCommand
/ BulkReadReply / BulkWriteReply, so this is purely a client-side
addition.

SDK (MxGatewaySession.cs):
- WriteBulkAsync(serverHandle, IReadOnlyList<WriteBulkEntry>, ct)
- Write2BulkAsync(serverHandle, IReadOnlyList<Write2BulkEntry>, ct)
- WriteSecuredBulkAsync(serverHandle, IReadOnlyList<WriteSecuredBulkEntry>, ct)
- WriteSecured2BulkAsync(serverHandle, IReadOnlyList<WriteSecured2BulkEntry>, ct)
- ReadBulkAsync(serverHandle, IReadOnlyList<string> tagAddresses, TimeSpan timeout, ct)

Per-entry secured user ids live on each WriteSecured(2)BulkEntry — they
are NOT lifted to ctor args because the proto field shape allows distinct
ids per row.

CLI (MxGatewayClientCli.cs):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk / write-secured2-bulk
  routed through the existing dispatch table, with --type, --values,
  --item-handles, --timeout-ms, --current-user-id, --verifier-user-id,
  --timestamp flags matching the cross-language CLI surface.
- bench-read-bulk benchmark harness: warmup + steady-state ReadBulk loop
  with p50/p95/p99/max/mean latency, emitting the shared JSON schema so
  scripts/bench-read-bulk.ps1 collates the .NET line alongside the four
  other clients.

The new subcommands flow through the existing batch dispatcher without
further changes.

Verification: dotnet build clean (0 warnings / 0 errors);
dotnet test 59/59 passing. Manual smoke against the live gateway
on localhost:5120: read-bulk returned 2 BulkReadResult entries with
wasSuccessful=true, wasCached=true; write-bulk on int32 returned
wasSuccessful=true; close-session returned SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:49:33 -04:00
Joseph Doherty 71d2c39f01 e2e: port batch subcommand to all five client CLIs
scripts/run-client-e2e-tests.ps1 expects each language CLI to expose a
`batch` subcommand that reads command lines from stdin, runs each
through the normal subcommand dispatch, writes the JSON result, then
a sentinel line `__MXGW_BATCH_EOR__`. The implementation lived on a
divergent branch (commit 6126099) that was never merged into main —
this commit ports the same protocol to HEAD's renamed CLIs so the
existing matrix script runs end-to-end.

The protocol:
  - one line of stdin = one full CLI invocation
  - successful output → stdout, then __MXGW_BATCH_EOR__
  - failure → {"error":"...","type":"error"} JSON on stdout, then
    __MXGW_BATCH_EOR__ (errors do NOT exit the loop)
  - empty line or EOF terminates the loop

Per-CLI additions:

  .NET: RunBatchAsync + per-line StringWriter capture, JSON error
    envelope when forceJsonErrors is true. Two new tests in
    MxGatewayClientCliTests covering the success and error paths.

  Go:   runBatch with bufio.Scanner, runs each line through the
    existing runWithIO switch with a buffered stdout writer. One new
    test pinning the EOR sentinel.

  Rust: new `Batch` variant on the clap Command enum, run_batch
    re-parses each line via Cli::try_parse_from. Two new tests in the
    inline mod tests block.

  Python: new `batch` click command in commands.py that uses
    CliRunner to dispatch each line; synthesises {"error",..."type"}
    JSON from click error messages when the captured output isn't
    already JSON-shaped. Three new tests in test_cli.py.

  Java: BatchCommand inner @Command with BufferedReader stdin loop,
    fresh commandLine() per dispatch with captured stdout/stderr
    PrintWriters; non-zero exit codes and uncaught exceptions both
    surface as JSON-error blocks. Two new tests.

Also fixes scripts/run-client-e2e-tests.ps1 line 705: the Python
invocation was still passing the old module name `mxgateway_cli` to
`python -m`; the client SDK rename in 397d3c5 moved it to
`zb_mom_ww_mxgateway_cli`. Without the fix the Python leg fails
with "No module named mxgateway_cli" before reaching open-session.

Verification: full matrix at the redeployed gateway (localhost:5120,
running ZB.MOM.WW.MxGateway.Server.exe / ZB.MOM.WW.MxGateway.Worker.exe)
with -SkipBulk -SkipReadWriteBulk -SkipParity -SkipAuth (those phases
exercise bulk read/write CLI subcommands that also live on the
divergent branch — porting those is a follow-up). All five clients
report `closed=true, addedItems=120, eventCount=5` and overall
`success=true`. Per-language unit tests pass:
  - dotnet: 59/59
  - go:     all packages clean
  - rust:   cargo test --workspace clean
  - python: 42/42
  - java:   gradle build SUCCESSFUL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:08:15 -04:00
Joseph Doherty a68f0cf222 code-review: regen README, all 21 open findings resolved
Closes the 2026-05-24 resolution sweep at HEAD `83eba4b`. All 21 open
findings from the d692232 re-review are now Resolved:

  Server          327e9c5  Server-031, -032, -038, -039, -040, -041, -042, -043
  Contracts       bd1d1f1  Contracts-016, -017
  Tests           d48099f  Tests-025, -026
  IntegrationTests 865c22a IntegrationTests-022, -023, -024
  Client.Java     10bd0c0  Client.Java-027, -028, -029, -030, -031
  Client.Rust     83eba4b  Client.Rust-021

Also fixes stale "Open findings" header counts in Client.Java and
IntegrationTests findings.md that survived the resolve passes
(the agents updated each finding's Status but missed the header
sum). `regen-readme.py --check` is now green.

Module status: 11 / 11 reviewed, 0 / 276 total open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:39 -04:00
Joseph Doherty 83eba4bec5 Resolve Client.Rust-021
Client.Rust-021 (Design adherence): RustClientDesign.md "Crate layout"
section now describes the actual flat workspace structure instead of
the aspirational nested form. The replacement text states that the
workspace root is clients/rust/, the top-level crate
zb-mom-ww-mxgateway-client is declared in clients/rust/Cargo.toml
directly, and crates/mxgw-cli/ is the sole [workspace.members] entry.
The accompanying tree lists the real files on disk (Cargo.toml,
Cargo.lock, build.rs, README.md, RustClientDesign.md,
src/{lib,client,session,galaxy,options,auth,error,value,version,generated}.rs
plus the src/generated/ tonic-build output dir, tests/, and
crates/mxgw-cli/).

Doc-only change. cargo build --workspace + cargo test --workspace clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:07 -04:00
Joseph Doherty 10bd0c0e4d Resolve Client.Java-027..031
Client.Java-027 (Documentation): Updated 17 Gradle task references in
clients/java/README.md (lines 37, 108-110, 160-161, 169-176, 186, 206,
221) and 3 in clients/java/JavaClientDesign.md from the retired short
subproject names to the canonical zb-mom-ww-mxgateway-client /
zb-mom-ww-mxgateway-cli names. Copy-pasting any documented command now
matches the subproject names declared in settings.gradle.

Client.Java-028 (Design adherence): Build-layout block in
JavaClientDesign.md lines 23-27 updated to show the actual package
paths com/zb/mom/ww/mxgateway/{client,cli}/ instead of the retired
com/dohertylan/mxgateway/{client,cli}/ paths.

Client.Java-029 (Documentation): README.md line 210 corrected from
"zb-mom-ww-mxgateway-cli/build/install/mxgateway-cli" to
"zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli" — Gradle
installDist produces a directory whose name matches the project name,
not the short suffix. The e2e script already used the correct path.

Client.Java-030 (Testing coverage): Added
queryActiveAlarmsForwardsRequestAndStreamsSnapshots to
MxGatewayClientSessionTests. The test pushes a QueryActiveAlarmsRequest
carrying session_id / client_correlation_id / alarm_filter_prefix
through an InProcessGateway + TestGatewayService and asserts the server
observed all three request fields, two ActiveAlarmSnapshots stream in
order, and onError is never called. TDD red→green confirmed via a
deliberately-wrong session_id assertion. The re-triage note in
Client.Java-030's resolution clarifies that the finding's reference to
"the existing acknowledgeAlarm test" was aspirational — the alarm RPC
surface had zero coverage before this commit.

Client.Java-031 (Conventions): README.md prose lines 17, 22, 26 updated
to use the canonical zb-mom-ww-mxgateway-client / zb-mom-ww-mxgateway-cli
names so the layout description matches Gradle / IDE project names.

Verification: gradle build BUILD SUCCESSFUL; all Java unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:06 -04:00
Joseph Doherty 865c22a884 Resolve IntegrationTests-022..024
IntegrationTests-022 (Conventions): ResolveRepositoryRoot now throws
InvalidOperationException when the walk exhausts without finding a root
marker, with a message naming the start directory, the expected markers
(src/, .git, *.sln, *.slnx), and the MXGATEWAY_LIVE_MXACCESS_WORKER_EXE
escape hatch. Replaces the silent fallback to
Directory.GetCurrentDirectory() that previously masked misconfiguration.
New regression test
ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers
in IntegrationTestEnvironmentTests asserts the throw and the message
contents. TDD red→green confirmed.

IntegrationTests-023 (Testing coverage): DashboardLdapLiveTests's
AuthenticateAsync_AdminInGwAdminGroup_Succeeds now asserts that the
authenticated principal carries a ClaimTypes.Role claim with value
DashboardRoles.Admin in addition to the existing LdapGroupClaimType
assertion. A regression in MapGroupsToRoles (returning an empty list or
missing the RDN fallback) would now surface here. Gated by
MXGATEWAY_RUN_LIVE_LDAP_TESTS.

IntegrationTests-024 (Conventions): Option (b) — extracted within
IntegrationTests. New file TestSupport/NullDashboardEventBroadcaster.cs
(public type, private ctor, singleton Instance). The inline class at
the bottom of WorkerLiveMxAccessSmokeTests is gone; the file now imports
the shared type. Matches the unit-test project's Tests-007 / Tests-021 /
Tests-025 pattern while keeping the two test projects independently
buildable (no shared test-helpers project crossing module boundaries).

Verification: dotnet build src/ZB.MOM.WW.MxGateway.IntegrationTests
clean; 19/19 integration tests passing (live MxAccess + LDAP + Galaxy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:40 -04:00
Joseph Doherty d48099f0d0 Resolve Tests-025..026
Tests-025 (Conventions): Extracted the previously-duplicated
NullDashboardEventBroadcaster into TestSupport/NullDashboardEventBroadcaster.cs
(singleton Instance, private ctor). The two nested copies in
EventStreamServiceTests and GatewayEndToEndFakeWorkerSmokeTests were
removed; both files now use the shared type via
'using ZB.MOM.WW.MxGateway.Tests.TestSupport;'. The Server-041 regression
test's ThrowingDashboardEventBroadcaster is intentionally left nested —
single-file usage doesn't warrant promotion to TestSupport. The third
copy in IntegrationTests/WorkerLiveMxAccessSmokeTests was handled by
IntegrationTests-024 in its own commit.

Tests-026 (Testing coverage): Added a new RecordingDashboardEventBroadcaster
test double in TestSupport — a thread-safe (ConcurrentQueue<DashboardEventCapture>)
recorder. New fixture
StreamEventsAsync_PublishesEachEventToDashboardBroadcaster in
EventStreamServiceTests pushes two events through the fake session and
asserts the broadcaster received both with the correct sessionId and
WorkerSequence. TDD red→green confirmed: the deliberately-wrong
"Expected 3, Actual 2" red phase proved the recording fake was actually
invoked by the production code path.

Verification: 486/486 server tests passing (485 previous + 1 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:40 -04:00
Joseph Doherty bd1d1f1c0e Resolve Contracts-016..017
Contracts-016 (Conventions): QueryActiveAlarmsRequest.session_id header
replaced with the unambiguous "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." Removes the ambiguity that the prior "reserved for future
use" wording introduced.

Contracts-017 (Documentation): The rpc QueryActiveAlarms comment now
includes the alarm_filter_prefix description: "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."

Both are proto-comment-only changes — no wire-format impact, no field
renumbering, and the regenerated MxaccessGateway.cs / MxaccessGatewayGrpc.cs
carry only the doc-comment delta. Added the additive-only regression
guard QueryActiveAlarmsRequest_PinsFieldNumbersAndRoundTripsPrefixFilter
to ProtobufContractRoundTripTests — pins
session_id=1 / client_correlation_id=2 / alarm_filter_prefix=3 by
descriptor lookup and round-trips the message with and without the
filter populated.

Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean;
ProtobufContractRoundTripTests 40/40 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:13 -04:00
Joseph Doherty 327e9c5f94 Resolve Server-031..032 (re-triaged) + Server-038..043
Server-031: re-triaged. The recommended gateway-side
"skip-while-command-in-flight" guard is already in place at
WorkerClient.HeartbeatLoopAsync via WorkerClientOptions.HeartbeatStuckCeiling
(default 75s = 5× HeartbeatGrace). Two regression tests pin the
behaviour. Recommendation #1 (decouple worker-side _writeLock) is a
Worker-module concern (Worker-017 / Worker-023) and out of scope here.

Server-032: re-triaged. Recommendation #2 (rich diagnostic) is already
in EnqueueWorkerEventAsync, with #3 (overflow grace) absorbed by the
TryWrite → WriteAsync-with-timeout fall-through. Test
EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic
pins the diagnostic string. Recommendation #1 (prose contract in
gateway.md / docs) is deferred — outside this pass's edit scope.

Server-038 (Security): EventsHub.SubscribeSession's missing per-session
ACL is documented with a TODO(per-session-acl) and a <remarks> block
explaining the v1 acceptance (any dashboard role can subscribe to any
session — non-secret metadata, redacted value logging). The per-session
ACL design lands in a follow-up once a session-scoped role exists.

Server-039 (Error handling): HubTokenService.Validate now rejects a
deserialized payload where both Name and NameIdentifier are null/empty.
New test file HubTokenServiceTests.cs covers the regression and five
sanity cases. TDD confirmed.

Server-040 (Conventions): MapGroupsToRoles gains a precedence comment
explaining "full literal match first, leading-RDN fallback;
OrdinalIgnoreCase via DashboardOptions.GroupToRole". Documentation-only.

Server-041 (Design adherence): EventStreamService.ProduceEventsAsync
wraps the broadcaster.Publish call in try/catch (Exception). The
producer loop and gRPC stream are no longer at the mercy of the
broadcaster's never-throw discipline. New regression test
StreamEventsAsync_WhenDashboardBroadcasterThrows_StillYieldsEventsAndDoesNotFaultSession.

Server-042 (Performance): DashboardSnapshotPublisher.ExecuteAsync now
mirrors AlarmsHubPublisher's reconnect loop — wraps the await foreach
in a while-not-cancelled, catches general exceptions, and Task.Delays
5s before retrying. An internal ctor accepts a shorter delay for the
test. New test file DashboardSnapshotPublisherTests.cs covers the
throw-then-yield reconnect path and the normal-completion case.

Server-043 (Documentation): HubTokenService class XML doc gains a
<remarks> describing the singleton lifetime, the two consumer scopes
(DashboardHubConnectionFactory scoped, HubTokenAuthenticationHandler
transient), and the thread-safety contract.

Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean
(0 warnings / 0 errors); src/ZB.MOM.WW.MxGateway.Tests 486/486 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:18:52 -04:00
463 changed files with 64429 additions and 4487 deletions
+6
View File
@@ -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
+24 -22
View File
@@ -8,10 +8,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
The architecture is a two-process design — read `gateway.md` before making structural changes:
- **Gateway** (`src/MxGateway.Server`, .NET 10, x64): ASP.NET Core gRPC server. Owns the public API, sessions, auth, the Blazor dashboard, and the Galaxy Repository SQL browse RPCs. **Never instantiates MXAccess COM directly.**
- **Worker** (`src/MxGateway.Worker`, .NET Framework 4.8, **x86**): one process per session. Owns one MXAccess COM instance on a dedicated STA, pumps Windows messages, and converts COM events to protobuf.
- **Gateway** (`src/ZB.MOM.WW.MxGateway.Server`, .NET 10, x64): ASP.NET Core gRPC server. Owns the public API, sessions, auth, the Blazor dashboard, and the Galaxy Repository SQL browse RPCs. **Never instantiates MXAccess COM directly.**
- **Worker** (`src/ZB.MOM.WW.MxGateway.Worker`, .NET Framework 4.8, **x86**): one process per session. Owns one MXAccess COM instance on a dedicated STA, pumps Windows messages, and converts COM events to protobuf.
- **IPC**: gateway↔worker uses one bidirectional named pipe per worker (`mxaccess-gateway-{gatewayPid}-{sessionId}`) with length-prefixed `WorkerEnvelope` protobuf frames. Gateway hosts the pipe server and launches the worker. **gRPC is not used inside the worker** — .NET Framework 4.8 doesn't have a first-class gRPC stack.
- **Contracts** (`src/MxGateway.Contracts`): multi-targets `net10.0;net48` and owns the `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`). All other projects consume the generated types from here. Do not hand-edit anything under `Generated/`.
- **Contracts** (`src/ZB.MOM.WW.MxGateway.Contracts`): multi-targets `net10.0;net48` and owns the `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`). All other projects consume the generated types from here. Do not hand-edit anything under `Generated/`.
The worker must do all MXAccess COM calls on its dedicated STA thread, and the STA loop must pump Windows messages (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) so MXAccess events deliver. A plain blocking queue on an STA is not enough.
@@ -19,42 +19,42 @@ The worker must do all MXAccess COM calls on its dedicated STA thread, and the S
```powershell
# Full solution build (gateway, worker, contracts, tests)
dotnet build src/MxGateway.sln
dotnet build src/ZB.MOM.WW.MxGateway.slnx
# Worker must be built x86 — the gateway looks for MxGateway.Worker.exe under bin\x86
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
# Worker must be built x86 — the gateway looks for ZB.MOM.WW.MxGateway.Worker.exe under bin\x86
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
# Gateway tests (no MXAccess required — uses FakeWorkerHarness)
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj
dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86
# Run gateway locally (defaults bound under MxGateway:* in src/MxGateway.Server/appsettings.json)
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
# Run gateway locally (defaults bound under MxGateway:* in src/ZB.MOM.WW.MxGateway.Server/appsettings.json)
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
# API-key admin CLI (same exe, "apikey" subcommand)
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
```
Single test by name (xUnit `--filter`):
```powershell
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
```
Live MXAccess integration tests are **opt-in** because they need installed MXAccess COM and live provider state:
```powershell
$env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = "1"
dotnet test src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests
```
Live LDAP tests use `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`. See `docs/GatewayTesting.md` for the full opt-in matrix and `LiveMxAccessFactAttribute` / `LiveLdapFactAttribute` for the gating logic.
## Clients
Each language client is in `clients/<lang>/` with its own README. They all consume the shared `.proto` files in `src/MxGateway.Contracts/Protos`:
Each language client is in `clients/<lang>/` with its own README. They all consume the shared `.proto` files in `src/ZB.MOM.WW.MxGateway.Contracts/Protos`:
- `clients/dotnet`: `dotnet build clients/dotnet/MxGateway.Client.sln`
- `clients/dotnet`: `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx`
- `clients/python`: `python -m pip install -e ".[dev]"; python -m pytest`
- `clients/rust`: `cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings`
- `clients/java`: `gradle test` (Java 21)
@@ -77,7 +77,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
- **Generated code** under `src/MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
@@ -85,12 +85,14 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
When source code changes, build and test the affected component before reporting work done. If the change crosses component boundaries, build each affected component — don't rely on a single top-level build:
**Run targeted tests per task, never the full suite each time.** When executing a plan task-by-task, run only the tests that exercise the code that task touched (`dotnet test --filter "FullyQualifiedName~<TestClass>"`, or the per-task test named in the plan). The full gateway suite is slow and leaves orphaned testhost processes — run it at most once per phase (after a related batch of tasks lands), not after every task.
| Changed area | Required verification |
|---|---|
| Contracts or `.proto` files | regenerate generated code, then build gateway, worker, and every generated client touched by the contract |
| Gateway server, sessions, workers, gRPC, dashboard, metrics | `dotnet build src/MxGateway.Server` and run affected gateway / fake-worker tests |
| Worker IPC, STA, MXAccess, conversion | `dotnet build src/MxGateway.Worker -p:Platform=x86` and run worker tests |
| .NET client | `dotnet build clients/dotnet/MxGateway.Client.sln` and run its tests |
| Gateway server, sessions, workers, gRPC, dashboard, metrics | `dotnet build src/ZB.MOM.WW.MxGateway.Server` and run affected gateway / fake-worker tests |
| Worker IPC, STA, MXAccess, conversion | `dotnet build src/ZB.MOM.WW.MxGateway.Worker -p:Platform=x86` and run worker tests |
| .NET client | `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` and run its tests |
| Go client | `gofmt`, `go build ./...`, `go test ./...` from `clients/go` |
| Rust client | `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings` from `clients/rust` |
| Python client | `python -m pytest` from `clients/python` |
@@ -100,7 +102,7 @@ When source code changes, build and test the affected component before reporting
## Design Sources To Consult Before Non-Trivial Changes
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
- `glauth.md`local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
- `glauth.md`shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
@@ -114,9 +116,9 @@ External analysis sources referenced by design docs:
## Authentication
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`.
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled.
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled. `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
## Process / Platform Notes
+140
View File
@@ -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.
+38
View File
@@ -0,0 +1,38 @@
<Project>
<PropertyGroup>
<!-- Build-quality enforcement floor, mirroring src/Directory.Build.props so the
.NET client tree is held to the same baseline CLAUDE.md mandates (warnings as
errors, code-style enforced at build, latest analyzers, deterministic builds). -->
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup>
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
<Authors>Joseph Doherty</Authors>
<Company>ZB MOM WW</Company>
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
<Product>MxAccessGateway Client</Product>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<!-- Proprietary/internal package, consistent with the Rust ("Proprietary") and
Python ("Proprietary") client license declarations. A LicenseRef SPDX expression
is rejected by the current NuGet toolset (NU5124), so the proprietary terms ship
as a packaged license file instead. -->
<PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
<Version>0.1.1</Version>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- Default: do NOT pack. Each project opts in. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
+19
View File
@@ -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:
+12
View File
@@ -0,0 +1,12 @@
Proprietary License
Copyright (c) ZB MOM WW. All rights reserved.
This software and its source code are proprietary and confidential. They are
licensed, not sold, for internal use within ZB MOM WW and its authorized
partners only. No part of this package may be reproduced, distributed, or
transmitted to third parties without the prior written permission of ZB MOM WW.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
+106
View File
@@ -84,6 +84,15 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
reference, optional comment, ack target). All three accept a cancellation
token and pass through the `MxGateway:Alarms` configuration on the
server — when alarms are disabled, the gateway returns an empty list / empty
stream rather than failing.
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
the first `CloseSessionReply` instead of sending another close request.
@@ -125,6 +134,8 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --s
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
@@ -185,6 +196,67 @@ 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.
The CLI counterpart is `galaxy-browse`. Without `--parent` it walks the root
objects and eagerly expands `--depth` further levels into an indented tree; with
`--parent <gobject-id>` it fetches exactly one level of children for that object
(`--depth` is ignored there). Filter flags map onto `BrowseChildrenOptions`:
`--category-ids` and `--template-contains` are comma-separated lists,
`--tag-name-glob` / `--alarm-bearing-only` / `--historized-only` are scalar, and
`--include-attributes` overrides the server default for attribute population.
```powershell
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-browse --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --depth 1
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-browse --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --parent 42 --json
```
### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
@@ -228,6 +300,17 @@ Use TLS options for a secured gateway:
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
### TLS trust
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with
no pinned CA accepts whatever certificate the gateway presents. To verify
instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces
the certificate hostname/SAN match), or set `RequireCertificateValidation` to
force OS/system-trust verification without pinning. Use `ServerNameOverride` /
`--server-name` when the dialed host differs from the certificate SAN. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
@@ -240,6 +323,29 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
```
## Installing as a NuGet Package
The client publishes to the internal Gitea NuGet feed at
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
Add the feed once:
````bash
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
--name dohertj2-gitea \
--username <gitea-username> \
--password <gitea-token-or-password> \
--store-password-in-clear-text
````
Then add the package to your project:
````bash
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.1
````
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
@@ -45,6 +45,27 @@ public interface IMxGatewayCliClient : IAsyncDisposable
StreamEventsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway.
/// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Attaches to the gateway's central alarm feed — the current active-alarm
/// snapshot followed by live transitions.
/// </summary>
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Tests connection to the Galaxy Repository.
/// </summary>
@@ -84,4 +105,16 @@ public interface IMxGatewayCliClient : IAsyncDisposable
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Fetches one page of direct children of a Galaxy parent (or the root
/// objects when the parent selector is unset), the primitive that backs the
/// lazy-browse helper.
/// </summary>
/// <param name="request">The browse-children request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The browse-children reply.</returns>
Task<BrowseChildrenReply> GalaxyBrowseChildrenAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken);
}
@@ -52,6 +52,22 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _client.StreamEventsAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken)
{
return _client.AcknowledgeAlarmAsync(request, cancellationToken);
}
/// <inheritdoc />
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CancellationToken cancellationToken)
{
return _client.StreamAlarmsAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
@@ -84,6 +100,14 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<BrowseChildrenReply> GalaxyBrowseChildrenAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.BrowseChildrenRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
using Grpc.Core;
using Grpc.Net.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
/// </summary>
public sealed class BrowseChildrenSmokeTests
{
/// <summary>
/// Verifies that BrowseChildren returns a non-zero cache sequence and
/// a consistent children/child-has-children count from a live gateway.
/// </summary>
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
{
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
Assert.True(reply.CacheSequence > 0UL);
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}
}
@@ -48,6 +48,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary>
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
/// <summary>Gets the queue of discover hierarchy replies; dequeued in FIFO order.</summary>
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
/// <summary>
@@ -122,6 +123,49 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
: DiscoverHierarchyReply);
}
/// <summary>Records BrowseChildren RPC calls made by the client.</summary>
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
/// <summary>Default reply returned from BrowseChildren when the queue is empty.</summary>
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
/// <summary>Queue of replies returned from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
/// <summary>
/// Optional hook awaited inside BrowseChildren before the reply is produced. Lets a
/// test hold an RPC mid-flight to exercise concurrent reads of the in-progress node.
/// </summary>
public Func<Task>? BrowseChildrenGate { get; set; }
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The BrowseChildrenRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
BrowseChildrenCalls.Add((request, callOptions));
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
if (BrowseChildrenGate is { } gate)
{
await gate().ConfigureAwait(false);
}
return BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
? reply
: BrowseChildrenReply;
}
/// <summary>
/// Gets the list of WatchDeployEvents RPC calls made by the client.
/// </summary>
@@ -51,6 +51,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary>
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
/// <summary>
/// Gets the list of captured StreamAlarmsAsync calls.
/// </summary>
public List<(StreamAlarmsRequest Request, CallOptions CallOptions)> StreamAlarmsCalls { get; } = [];
/// <summary>
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
/// </summary>
@@ -58,6 +63,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
private readonly List<AlarmFeedMessage> _alarmFeedMessages = [];
/// <summary>
/// Gets or sets the reply to return from OpenSessionAsync.
@@ -190,6 +196,8 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// <summary>
/// Records the acknowledge call and returns the next enqueued reply (or default).
/// </summary>
/// <param name="request">The acknowledge alarm request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CallOptions callOptions)
@@ -213,6 +221,8 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// <summary>
/// Records the query call and yields each enqueued snapshot.
/// </summary>
/// <param name="request">The query active alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions)
@@ -228,14 +238,42 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
}
/// <summary>Enqueues an acknowledge reply.</summary>
/// <param name="reply">The acknowledge reply to enqueue.</param>
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
{
_acknowledgeReplies.Enqueue(reply);
}
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
/// <param name="snapshot">The snapshot to enqueue.</param>
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
{
_activeAlarmSnapshots.Add(snapshot);
}
/// <summary>
/// Records the stream-alarms call and yields each enqueued feed message.
/// </summary>
/// <param name="request">The stream alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions)
{
StreamAlarmsCalls.Add((request, callOptions));
foreach (AlarmFeedMessage message in _alarmFeedMessages)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return message;
}
}
/// <summary>Enqueues an alarm feed message to be yielded from StreamAlarmsAsync.</summary>
/// <param name="message">The alarm feed message to enqueue.</param>
public void AddAlarmFeedMessage(AlarmFeedMessage message)
{
_alarmFeedMessages.Add(message);
}
}
@@ -181,6 +181,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
{
@@ -212,6 +215,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.True(request.HistorizedOnly);
}
/// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary>
[Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{
@@ -0,0 +1,311 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Tests for the <see cref="LazyBrowseNode"/> walker over the BrowseChildren RPC.
/// </summary>
public sealed class LazyBrowseNodeTests
{
/// <summary>
/// Verifies that calling BrowseAsync with no parent returns the root nodes
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
/// </summary>
[Fact]
public async Task Browse_NoParent_ReturnsRoots()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")],
childHasChildren: [true, false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
Assert.Equal(2, roots.Count);
Assert.Equal("Plant", roots[0].Object.TagName);
Assert.True(roots[0].HasChildrenHint);
Assert.False(roots[0].IsExpanded);
Assert.Equal("Other", roots[1].Object.TagName);
Assert.False(roots[1].HasChildrenHint);
Assert.False(roots[1].IsExpanded);
}
/// <summary>
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
/// </summary>
[Fact]
public async Task Expand_PopulatesChildrenAndMarksExpanded()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
Assert.Equal("Line1", roots[0].Children[0].Object.TagName);
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
/// </summary>
[Fact]
public async Task Expand_CalledTwice_NoSecondRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
await roots[0].ExpandAsync();
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
/// </summary>
[Fact]
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Queue the failure for the upcoming ExpandAsync call so it consumes
// the exception on its first RPC rather than the BrowseAsync above.
transport.BrowseChildrenExceptions.Enqueue(
new MxGatewayException(
"Parent not found",
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
Assert.False(roots[0].IsExpanded);
Assert.Empty(roots[0].Children);
}
/// <summary>
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
/// </summary>
[Fact]
public async Task Expand_MultiPageSiblings_GathersAllPages()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Roots
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(7, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
// First child page (2 children) with a next token
BrowseChildrenReply childPage1 = BuildReply(
children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")],
childHasChildren: [false, false],
cacheSequence: 1);
childPage1.NextPageToken = "7:abc:2";
transport.BrowseChildrenReplies.Enqueue(childPage1);
// Second child page (1 child) with no next token
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(72, "ChildC")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.Equal(3, roots[0].Children.Count);
Assert.Equal(3, transport.BrowseChildrenCalls.Count);
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
}
/// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary>
[Fact]
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 7));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(2, "Mixer_001")],
childHasChildren: [false],
cacheSequence: 7));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Fire ten concurrent expands of the same node.
Task[] tasks = Enumerable.Range(0, 10)
.Select(_ => roots[0].ExpandAsync())
.ToArray();
await Task.WhenAll(tasks);
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
// 1 roots fetch + exactly 1 expand fetch = 2 total
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that reading Children/IsExpanded concurrently with an in-flight ExpandAsync
/// never throws (no torn enumeration of a mid-append list) and, once IsExpanded flips to
/// true, the published Children snapshot is fully populated. Pins the safe-publication
/// contract on the lock-free readers (Client.Dotnet-025).
/// </summary>
[Fact]
public async Task Expand_ConcurrentReadOfChildren_NeverTearsAndPublishesAtomically()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
// Multi-page child set so the expand loop spends meaningful time appending,
// widening the window for a concurrent reader to observe a torn list.
BrowseChildrenReply childPage1 = BuildReply(
children: [BuildObject(10, "A"), BuildObject(11, "B"), BuildObject(12, "C")],
childHasChildren: [false, false, false],
cacheSequence: 1);
childPage1.NextPageToken = "1:p:3";
transport.BrowseChildrenReplies.Enqueue(childPage1);
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(13, "D"), BuildObject(14, "E")],
childHasChildren: [false, false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
LazyBrowseNode node = roots[0];
// Gate the child-page RPCs so the expand stays mid-flight while the reader spins.
using SemaphoreSlim release = new(0, 1);
bool firstChildCall = true;
transport.BrowseChildrenGate = async () =>
{
if (firstChildCall)
{
firstChildCall = false;
await release.WaitAsync().ConfigureAwait(false);
}
};
using CancellationTokenSource readerStop = new();
Exception? readerFailure = null;
Task reader = Task.Run(() =>
{
try
{
while (!readerStop.IsCancellationRequested)
{
bool expanded = node.IsExpanded;
// Enumerate the snapshot; a torn/mid-append list would throw here.
int count = 0;
foreach (LazyBrowseNode _ in node.Children)
{
count++;
}
// If the node reports expanded, the published snapshot must be complete.
if (expanded)
{
Assert.Equal(5, count);
}
}
}
catch (Exception ex)
{
readerFailure = ex;
}
});
Task expand = node.ExpandAsync();
// Let the reader spin against the empty pre-publication snapshot for a moment.
await Task.Delay(50);
release.Release();
await expand;
// Let the reader observe the post-publication state, then stop it.
await Task.Delay(50);
readerStop.Cancel();
await reader;
Assert.Null(readerFailure);
Assert.True(node.IsExpanded);
Assert.Equal(5, node.Children.Count);
}
/// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary>
[Fact]
public async Task Browse_WithFilter_ForwardsToRequest()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.BrowseAsync(new BrowseChildrenOptions
{
TagNameGlob = "Mixer*",
AlarmBearingOnly = true,
});
BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
Assert.Equal("Mixer*", request.TagNameGlob);
Assert.True(request.AlarmBearingOnly);
}
private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
=> new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
private static BrowseChildrenReply BuildReply(
IReadOnlyList<GalaxyObject> children,
IReadOnlyList<bool> childHasChildren,
ulong cacheSequence)
{
BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
reply.Children.AddRange(children);
reply.ChildHasChildren.AddRange(childHasChildren);
return reply;
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
=> new(transport.Options, transport);
private static FakeGalaxyRepositoryTransport CreateTransport()
=> new(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// </summary>
public sealed class MxGatewayClientAlarmsTests
{
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
{
@@ -46,6 +47,7 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
{
@@ -69,6 +71,7 @@ public sealed class MxGatewayClientAlarmsTests
cancellation.Token));
}
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
{
@@ -93,6 +96,7 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
}
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
{
@@ -117,6 +121,7 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Single(transport.QueryActiveAlarmsCalls);
}
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
{
@@ -136,6 +141,7 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
}
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
{
@@ -148,6 +148,87 @@ public sealed class MxGatewayClientCliTests
}
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
[Fact]
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
{
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
});
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });
int exitCode = await MxGatewayClientCli.RunAsync(
[
"stream-alarms",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--filter-prefix",
"Tank01",
"--max-events",
"1",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
Assert.Equal("Tank01", request.AlarmFilterPrefix);
string text = output.ToString();
Assert.Contains("active-alarm", text);
Assert.Contains("Tank01.Level.HiHi", text);
Assert.DoesNotContain("snapshot-complete", text);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
[Fact]
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
{
CorrelationId = "ack-fixture",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"acknowledge-alarm",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--reference",
"Tank01.Level.HiHi",
"--comment",
"ack from cli",
"--operator",
"operator1",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
Assert.Equal("ack from cli", request.Comment);
Assert.Equal("operator1", request.OperatorUser);
Assert.Contains("ack-fixture", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
[Fact]
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
@@ -279,6 +360,146 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Verifies galaxy-browse walks root objects and eagerly expands one further
/// level when --depth 1 is passed, printing an indented tree.
/// </summary>
[Fact]
public async Task RunAsync_GalaxyBrowse_TextTreeExpandsToDepth()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
// Root level (parent 0): one area with a child hint.
fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue<BrowseChildrenReply>(
[
new BrowseChildrenReply
{
Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } },
ChildHasChildren = { true },
},
]);
// Children of gobject 10.
fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue<BrowseChildrenReply>(
[
new BrowseChildrenReply
{
Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } },
},
]);
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-browse",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--depth",
"1",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
string text = output.ToString();
Assert.Contains("Area_001", text);
Assert.Contains("Tank_001", text);
// Children are indented beneath their parent (two-space indent per level).
Assert.Matches(@"\n \d+\tTank_001", text);
// Root fetched with the parent oneof unset; child fetch used parent 10.
Assert.Contains(
fakeClient.GalaxyBrowseChildrenRequests,
request => request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId
&& request.ParentGobjectId == 10);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Verifies galaxy-browse --json emits a nested JSON document and forwards
/// the filter flags onto the BrowseChildren request.
/// </summary>
[Fact]
public async Task RunAsync_GalaxyBrowse_JsonForwardsFilters()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue<BrowseChildrenReply>(
[
new BrowseChildrenReply
{
Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } },
},
]);
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-browse",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--tag-name-glob",
"Area*",
"--alarm-bearing-only",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(output.ToString());
Assert.Equal("galaxy-browse", document.RootElement.GetProperty("command").GetString());
Assert.True(document.RootElement.GetProperty("nodes").GetArrayLength() >= 1);
BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests);
Assert.Equal("Area*", request.TagNameGlob);
Assert.True(request.AlarmBearingOnly);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Verifies galaxy-browse --parent fetches exactly one level of children for
/// the supplied gobject id via a parent-scoped BrowseChildren request.
/// </summary>
[Fact]
public async Task RunAsync_GalaxyBrowse_ParentFetchesSingleLevel()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue<BrowseChildrenReply>(
[
new BrowseChildrenReply
{
Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } },
},
]);
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-browse",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--parent",
"10",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Contains("Tank_001", output.ToString());
BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests);
Assert.Equal(BrowseChildrenRequest.ParentOneofCase.ParentGobjectId, request.ParentCase);
Assert.Equal(10, request.ParentGobjectId);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
[Fact]
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
@@ -368,6 +589,416 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("\"objectCount\": 99", text);
}
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
[Fact]
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
{
using var output = new StringWriter();
using var error = new StringWriter();
using var input = new StringReader("version --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: input);
Assert.Equal(0, exitCode);
string text = output.ToString();
Assert.Contains("\"gatewayProtocolVersion\":3", text);
Assert.Contains("__MXGW_BATCH_EOR__", text);
// The EOR marker must come after the JSON output.
int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
[Fact]
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
{
using var output = new StringWriter();
using var error = new StringWriter();
// Unknown command should produce an error on the captured error stream,
// which batch mode re-emits to stdout inside the same delimited block.
using var input = new StringReader("nope-not-a-command\nversion\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: input);
Assert.Equal(0, exitCode);
string text = output.ToString();
// Two records → two EOR markers.
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf(
"__MXGW_BATCH_EOR__",
firstEor + 1,
StringComparison.Ordinal);
Assert.True(firstEor > 0);
Assert.True(secondEor > firstEor);
// The unknown-command error message must be on stdout (not on stderr).
Assert.Contains("nope-not-a-command", text);
Assert.DoesNotContain("nope-not-a-command", error.ToString());
// The follow-up `version` line should still succeed.
Assert.Contains("gateway-protocol=", text);
}
/// <summary>
/// Client.Dotnet-018: the README CLI examples for the alarm subcommands at
/// `clients/dotnet/README.md` must drive cleanly through the production
/// CLI argument parser. The previous text used non-existent flags
/// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
/// fail with "Unknown command" / "Missing required option --reference".
/// Each documented example is extracted from the README, parsed via the
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
/// against exit code 0.
/// </summary>
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
[Theory]
[InlineData("stream-alarms")]
[InlineData("acknowledge-alarm")]
public async Task RunAsync_ReadmeExamples_ForAlarmCommands_ParseSuccessfully(string command)
{
string readme = LocateClientReadme();
string[] commandLine = ExtractReadmeCommandLine(readme, command);
// The documented examples do not include --api-key (the README assumes
// the env var path documented elsewhere). Inject an API key via the
// standard env var so CreateOptions succeeds and the parser fully
// exercises the documented flag shape.
string? previousKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", "test-api-key");
try
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
{
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "fixture" },
});
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
{
CorrelationId = "ack-fixture",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
int exitCode = await MxGatewayClientCli.RunAsync(
commandLine,
output,
error,
_ => fakeClient);
Assert.True(
exitCode == 0,
$"README example for '{command}' exited {exitCode}; stderr=<<{error}>>");
Assert.DoesNotContain("Unknown command", error.ToString());
Assert.DoesNotContain("Missing required option", error.ToString());
}
finally
{
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", previousKey);
}
}
/// <summary>
/// Client.Dotnet-019: `BenchReadBulkAsync` previously fell back to
/// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
/// typed <c>Register</c> payload, silently driving the rest of the bench
/// against a zero server handle. The fix must fail loudly with a
/// descriptive <see cref="MxGatewayException"/>.
/// </summary>
[Fact]
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
// Successful protocol + MX status but no typed `Register` payload.
// Before the Client.Dotnet-019 fix this silently became serverHandle=0
// and the bench proceeded through SubscribeBulk / warmup / steady-state
// against an invalid handle, producing a misleading zero-result summary.
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Register,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"bench-read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--duration-seconds",
"1",
"--warmup-seconds",
"0",
"--bulk-size",
"1",
],
output,
error,
_ => fakeClient);
Assert.Equal(1, exitCode);
// Descriptive message that names the missing typed payload.
string err = error.ToString();
Assert.Contains("Register", err);
// The bench must not produce any aggregate stats JSON.
Assert.DoesNotContain("bench-read-bulk", output.ToString());
}
/// <summary>
/// Client.Dotnet-020: the steady-state loop in `BenchReadBulkAsync` had a
/// bare `catch { failedCalls++; continue; }` that swallowed
/// <see cref="OperationCanceledException"/>, so token-driven cancellation
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
/// the bench must exit promptly when the supplied token cancels.
/// </summary>
[Fact]
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
{
using var output = new StringWriter();
using var error = new StringWriter();
int invokeCount = 0;
FakeCliClient fakeClient = new()
{
InvokeHandler = (request, ct) =>
{
int n = Interlocked.Increment(ref invokeCount);
// Reply 1 = Register (success with typed payload).
if (request.Command.Kind == MxCommandKind.Register)
{
return Task.FromResult(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Register,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Register = new RegisterReply { ServerHandle = 1 },
});
}
// Reply 2 = SubscribeBulk (success).
if (request.Command.Kind == MxCommandKind.SubscribeBulk)
{
var subscribeReply = new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
SubscribeBulk = new BulkSubscribeReply(),
};
return Task.FromResult(subscribeReply);
}
// ReadBulk reply 1 = success (so the steady-state loop enters
// and starts iterating). Reply 2+ = simulated cancellation.
if (request.Command.Kind == MxCommandKind.ReadBulk && n <= 3)
{
return Task.FromResult(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
ReadBulk = new BulkReadReply(),
});
}
// From here on every ReadBulk throws OCE — the steady-state
// loop must exit promptly rather than spinning until
// --duration-seconds elapses.
throw new OperationCanceledException();
},
};
var sw = System.Diagnostics.Stopwatch.StartNew();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
await MxGatewayClientCli.RunAsync(
[
"bench-read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--duration-seconds",
"30",
"--warmup-seconds",
"0",
"--bulk-size",
"1",
],
output,
error,
_ => fakeClient));
sw.Stop();
// Without the fix the loop swallows OCE and continues until the 30 s
// steady-state deadline expires. With the fix it exits as soon as OCE
// surfaces. Generous 10 s ceiling to keep the test stable under load.
Assert.True(
sw.Elapsed < TimeSpan.FromSeconds(10),
$"Bench did not exit promptly on cancellation; took {sw.Elapsed}.");
}
/// <summary>
/// Client.Dotnet-021: both `ReadBulkAsync` and `BenchReadBulkAsync` cast
/// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
/// to ~49.7 days. The fix must reject negatives with a clear error.
/// </summary>
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
[Theory]
[InlineData("read-bulk")]
[InlineData("bench-read-bulk")]
public async Task RunAsync_TimeoutMs_NegativeValue_RejectsWithClearError(string command)
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
string[] args = command is "read-bulk"
? [
"read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--session-id",
"session-fixture",
"--server-handle",
"1",
"--items",
"Area001.Pump001.Speed",
"--timeout-ms",
"-1",
]
: [
"bench-read-bulk",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--duration-seconds",
"1",
"--warmup-seconds",
"0",
"--bulk-size",
"1",
"--timeout-ms",
"-1",
];
int exitCode = await MxGatewayClientCli.RunAsync(
args,
output,
error,
_ => fakeClient);
Assert.NotEqual(0, exitCode);
string err = error.ToString();
Assert.Contains("timeout-ms", err);
Assert.Contains("non-negative", err);
}
/// <summary>
/// Locates the .NET client README by walking up from the test assembly's
/// base directory until <c>clients/dotnet/README.md</c> is found. Keeps
/// the regression test independent of the current working directory.
/// </summary>
private static string LocateClientReadme()
{
string? directory = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(directory))
{
string candidate = Path.Combine(directory, "clients", "dotnet", "README.md");
if (File.Exists(candidate))
{
return candidate;
}
directory = Path.GetDirectoryName(directory);
}
throw new FileNotFoundException("clients/dotnet/README.md not found above test assembly base directory.");
}
/// <summary>
/// Extracts the documented CLI invocation for the requested subcommand
/// from the README, returning only the arguments after the
/// <c>mxgw-dotnet</c>-equivalent prefix so they can be passed straight
/// to <see cref="MxGatewayClientCli.RunAsync"/>.
/// </summary>
private static string[] ExtractReadmeCommandLine(string readmePath, string command)
{
string[] lines = File.ReadAllLines(readmePath);
// Look for the documented `dotnet run ... -- <command> ...` line.
foreach (string line in lines)
{
int dashes = line.IndexOf("-- " + command, StringComparison.Ordinal);
if (dashes < 0)
{
continue;
}
string after = line[(dashes + 3)..].Trim();
// Tokenize by whitespace, respecting "..." quoted segments.
return TokenizeCommandLine(after);
}
throw new InvalidOperationException(
$"README at '{readmePath}' has no documented example for subcommand '{command}'.");
}
/// <summary>
/// Splits a single command-line string into argv tokens, honouring
/// double-quoted segments so paths with embedded spaces survive intact.
/// </summary>
private static string[] TokenizeCommandLine(string input)
{
var tokens = new List<string>();
var current = new System.Text.StringBuilder();
bool inQuotes = false;
foreach (char ch in input)
{
if (ch == '"')
{
inQuotes = !inQuotes;
continue;
}
if (!inQuotes && char.IsWhiteSpace(ch))
{
if (current.Length > 0)
{
tokens.Add(current.ToString());
current.Clear();
}
continue;
}
current.Append(ch);
}
if (current.Length > 0)
{
tokens.Add(current.ToString());
}
return tokens.ToArray();
}
/// <summary>Fake CLI client for testing.</summary>
private sealed class FakeCliClient : IMxGatewayCliClient
{
@@ -386,6 +1017,9 @@ public sealed class MxGatewayClientCliTests
/// <summary>Exception to throw on invoke, if any.</summary>
public Exception? InvokeFailure { get; init; }
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
/// <inheritdoc />
public ValueTask DisposeAsync()
{
@@ -431,6 +1065,11 @@ public sealed class MxGatewayClientCliTests
throw InvokeFailure;
}
if (InvokeHandler is not null)
{
return InvokeHandler(request, cancellationToken);
}
return Task.FromResult(InvokeReplies.Dequeue());
}
@@ -447,6 +1086,41 @@ public sealed class MxGatewayClientCliTests
}
}
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();
/// <summary>List of received acknowledge-alarm requests.</summary>
public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];
/// <summary>List of received stream-alarms requests.</summary>
public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];
/// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];
/// <inheritdoc />
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken)
{
AcknowledgeAlarmRequests.Add(request);
return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
}
/// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
StreamAlarmsRequests.Add(request);
foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return feedMessage;
}
}
/// <summary>Galaxy test connection reply to return.</summary>
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
@@ -456,6 +1130,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>
@@ -516,5 +1191,33 @@ public sealed class MxGatewayClientCliTests
yield return deployEvent;
}
}
/// <summary>List of received galaxy browse-children requests, in call order.</summary>
public List<BrowseChildrenRequest> GalaxyBrowseChildrenRequests { get; } = [];
/// <summary>
/// Per-parent browse-children replies keyed by <c>parent_gobject_id</c>
/// (0 = root). Each parent's queue is dequeued in page order; an absent
/// or exhausted queue yields an empty reply.
/// </summary>
public Dictionary<int, Queue<BrowseChildrenReply>> GalaxyBrowseChildrenReplies { get; } = [];
/// <inheritdoc />
public Task<BrowseChildrenReply> GalaxyBrowseChildrenAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken)
{
GalaxyBrowseChildrenRequests.Add(request);
int parentId = request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId
? request.ParentGobjectId
: 0;
if (GalaxyBrowseChildrenReplies.TryGetValue(parentId, out Queue<BrowseChildrenReply>? queue)
&& queue.TryDequeue(out BrowseChildrenReply? reply))
{
return Task.FromResult(reply);
}
return Task.FromResult(new BrowseChildrenReply());
}
}
}
@@ -0,0 +1,85 @@
using System.Net.Http;
using System.Net.Security;
using ZB.MOM.WW.MxGateway.Client;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
public sealed class GalaxyRepositoryClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync(BrowseChildrenOptions, System.Threading.CancellationToken)"/>.
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
/// </summary>
public sealed class BrowseChildrenOptions
{
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
public IReadOnlyList<int> CategoryIds { get; init; } = [];
/// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
public string? TagNameGlob { get; init; }
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
public bool? IncludeAttributes { get; init; }
/// <summary>Restrict to children that bear at least one alarm attribute.</summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>Restrict to children that have at least one historized attribute.</summary>
public bool HistorizedOnly { get; init; }
}
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Client;
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private const int DiscoverHierarchyPageSize = 5000;
private const int BrowseChildrenPageSize = 500;
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
@@ -182,6 +183,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
}
/// <summary>Discovers the Galaxy object hierarchy.</summary>
/// <param name="options">Client configuration options.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
DiscoverHierarchyOptions options,
CancellationToken cancellationToken = default)
@@ -274,6 +279,89 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
cancellationToken);
}
/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
=> BrowseAsync(null, cancellationToken);
/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
/// <param name="options">Browse filter options. Null applies no filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
BrowseChildrenOptions? options,
CancellationToken cancellationToken = default)
{
BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
List<LazyBrowseNode> roots = [];
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
request.PageToken = pageToken;
BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return roots;
}
/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
/// <param name="request">The browse-children request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
cancellationToken);
}
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
{
ArgumentNullException.ThrowIfNull(options);
BrowseChildrenRequest request = new()
{
PageSize = BrowseChildrenPageSize,
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
/// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
@@ -402,7 +490,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
@@ -422,6 +513,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null)
{
return false;
@@ -437,6 +533,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return customChain.Build(certificateToValidate);
};
}
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
}
return handler;
@@ -74,6 +74,23 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
}
}
/// <inheritdoc />
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.BrowseChildrenAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
@@ -175,6 +175,48 @@ internal sealed class GrpcMxGatewayClientTransport(
return QueryActiveAlarmsAsync(request, callOptions);
}
/// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<AlarmFeedMessage> call = RawClient.StreamAlarms(request, callOptions);
IAsyncStreamReader<AlarmFeedMessage> responseStream = call.ResponseStream;
while (true)
{
AlarmFeedMessage? message;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
message = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return message;
}
}
/// <inheritdoc />
IAsyncEnumerable<AlarmFeedMessage> IMxGatewayClientTransport.StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions)
{
return StreamAlarmsAsync(request, callOptions);
}
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
@@ -33,6 +33,13 @@ internal interface IGalaxyRepositoryClientTransport
DiscoverHierarchyRequest request,
CallOptions callOptions);
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
/// <param name="request">The browse children request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions);
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
/// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
@@ -75,4 +75,15 @@ internal interface IMxGatewayClientTransport
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions);
/// <summary>
/// Attaches to the gateway's central alarm feed — the current active-alarm
/// snapshot followed by live transitions.
/// </summary>
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions);
}
@@ -0,0 +1,120 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
/// its direct children on demand. Expansion is one-shot: a second call is a
/// no-op. Pagination of large sibling sets is handled internally.
/// </summary>
public sealed class LazyBrowseNode
{
private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options;
private readonly SemaphoreSlim _expandLock = new(1, 1);
// Published once, under _expandLock, when expansion completes. Lock-free readers
// see either the empty pre-expansion snapshot or the fully-populated post-expansion
// snapshot — never a partially-filled list — because the snapshot is built in a local
// and handed off via Volatile.Write (release) paired with Volatile.Read (acquire).
private IReadOnlyList<LazyBrowseNode> _children = [];
private volatile bool _isExpanded;
internal LazyBrowseNode(
GalaxyRepositoryClient client,
GalaxyObject @object,
bool hasChildrenHint,
BrowseChildrenOptions options)
{
_client = client;
Object = @object;
HasChildrenHint = hasChildrenHint;
_options = options;
}
/// <summary>The underlying Galaxy object for this node.</summary>
public GalaxyObject Object { get; }
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
public bool HasChildrenHint { get; }
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
public IReadOnlyList<LazyBrowseNode> Children => Volatile.Read(ref _children);
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
public bool IsExpanded => _isExpanded;
/// <summary>
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
/// Idempotent: subsequent calls are no-ops.
/// </summary>
/// <remarks>
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
/// (after the first completes) return immediately. <see cref="Children"/> and
/// <see cref="IsExpanded"/> may be read concurrently with an in-flight
/// <see cref="ExpandAsync"/> on another thread; the populated children are
/// published as an immutable snapshot under a release barrier, so a reader that
/// observes <see cref="IsExpanded"/> as <see langword="true"/> always sees the
/// fully-populated <see cref="Children"/>, and a reader never enumerates a
/// partially-built list.
/// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task ExpandAsync(CancellationToken cancellationToken = default)
{
if (_isExpanded)
{
return;
}
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_isExpanded)
{
return;
}
// Accumulate into a local list, never the published field, so a lock-free
// reader can never observe a half-populated collection or enumerate a list
// that is being mutated mid-append.
List<LazyBrowseNode> children = [];
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
request.ParentGobjectId = Object.GobjectId;
request.PageToken = pageToken;
BrowseChildrenReply reply = await _client
.BrowseChildrenRawAsync(request, cancellationToken)
.ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
// Publish the completed, immutable snapshot (release) before marking the node
// expanded (the volatile write below). A reader that observes IsExpanded == true
// is guaranteed to also observe the fully-populated Children.
Volatile.Write(ref _children, children);
_isExpanded = true;
}
finally
{
_expandLock.Release();
}
}
}
@@ -224,6 +224,28 @@ public sealed class MxGatewayClient : IAsyncDisposable
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
}
/// <summary>
/// Attaches to the gateway's central alarm feed. The stream opens with one
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
/// ConditionRefresh snapshot), then a single <c>snapshot_complete</c>, then a
/// <c>transition</c> for every subsequent raise / acknowledge / clear. Served
/// by the gateway's always-on alarm monitor — no worker session is opened, so
/// any number of clients may attach. Optionally scoped by alarm-reference
/// prefix (<see cref="StreamAlarmsRequest.AlarmFilterPrefix"/>).
/// </summary>
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
/// <param name="cancellationToken">Cancellation token for the stream.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.StreamAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
}
/// <summary>
/// Disposes the client and releases all resources.
/// </summary>
@@ -293,7 +315,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
@@ -313,6 +338,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null)
{
return false;
@@ -328,6 +358,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
return customChain.Build(certificateToValidate);
};
}
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
}
return handler;
@@ -7,9 +7,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
/// </summary>
public static class MxGatewayClientContractInfo
{
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
public const uint GatewayProtocolVersion =
GatewayContractInfo.GatewayProtocolVersion;
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
public const uint WorkerProtocolVersion =
GatewayContractInfo.WorkerProtocolVersion;
}
@@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions
/// </summary>
public string? CaCertificatePath { get; init; }
/// <summary>
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
/// use the OS trust store. When false (default), the gateway certificate is
/// accepted without verification — appropriate for this internal tool's
/// auto-generated self-signed certificate. Pinning a CA always verifies.
/// </summary>
public bool RequireCertificateValidation { get; init; }
/// <summary>
/// Gets the server name override for SNI during TLS handshake.
/// </summary>
@@ -47,6 +55,9 @@ public sealed class MxGatewayClientOptions
/// </summary>
public TimeSpan? StreamTimeout { get; init; }
/// <summary>
/// Gets the maximum size in bytes for gRPC messages.
/// </summary>
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
/// <summary>
@@ -502,6 +502,171 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
/// Protocol-level failures still throw via EnsureProtocolSuccess.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, and user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
WriteBulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteBulk,
WriteBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.WriteBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, and user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
Write2BulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Write2Bulk,
Write2Bulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Write2Bulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk WriteSecured — sequential MXAccess WriteSecured per entry.
/// Credential-sensitive values must never reach logs; the client mirrors
/// the single-item WriteSecured redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, current user id, and verifier user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
WriteSecuredBulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteSecuredBulk,
WriteSecuredBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.
/// Same redaction rules as <see cref="WriteSecuredBulkAsync"/>.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, current user id, and verifier user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
WriteSecured2BulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteSecured2Bulk,
WriteSecured2Bulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.WriteSecured2Bulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk Read — snapshot the current value for each requested tag.
/// Returns the cached OnDataChange value when the tag is already advised
/// (<c>WasCached = true</c>), otherwise the worker takes the full AddItem +
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
/// failures (timeout, invalid tag) appear as <see cref="BulkReadResult"/>
/// entries with <c>WasSuccessful = false</c>; the call never throws on
/// per-tag errors.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="tagAddresses">Tag addresses to read (one per result).</param>
/// <param name="timeout">Per-call timeout for the snapshot lifecycle path; <see cref="TimeSpan.Zero"/> uses the gateway default.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkReadResult"/> per requested tag, in request order.</returns>
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
ReadBulkCommand command = new()
{
ServerHandle = serverHandle,
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
};
command.TagAddresses.Add(tagAddresses);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.ReadBulk,
ReadBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.ReadBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Writes a value to an item on the MXAccess server.
/// </summary>
@@ -16,4 +16,26 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and
Tests projects are not packable and do not document their public surface, so this
stays out of the shared Directory.Build.props to avoid CS1591 on test classes. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="\" />
<None Include="..\LICENSE.txt" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+17
View File
@@ -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:
+112
View File
@@ -75,6 +75,14 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
})
```
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
true` to verify against the OS/system trust roots without pinning. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`,
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
@@ -84,6 +92,13 @@ goroutine cleanup. Raw protobuf messages remain available through the
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
call returns a `StreamAlarmsClient`; cancel its context to terminate the
stream. All three pass straight through to the gateway's central alarm
monitor.
## Galaxy Repository browse
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
@@ -114,6 +129,68 @@ reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access.
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildren` to walk one level at a
time instead of loading the full hierarchy. Pass an empty request for root
objects; subsequent calls set `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Filter fields match `DiscoverHierarchy`. Each response
pairs `Children` with `ChildHasChildren` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```go
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated/galaxy_repository/v1"
reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
if err != nil {
return err
}
for i, child := range reply.GetChildren() {
fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
log.Fatal(err)
}
defer galaxy.Close()
roots, err := galaxy.Browse(ctx, nil)
if err != nil {
log.Fatal(err)
}
for _, root := range roots {
if root.HasChildrenHint() {
if err := root.Expand(ctx); err != nil {
log.Fatal(err)
}
}
for _, child := range root.Children() {
kind := "leaf"
if child.HasChildrenHint() {
kind = "has children"
}
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
}
}
```
`Expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`Browse` again from the root.
### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
@@ -206,6 +283,41 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
```
## Installing the Go client
The module is resolved directly from the git repo — no package registry:
````bash
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.1
````
Then import:
````go
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
````
If your build environment cannot reach `gitea.dohertylan.com` directly,
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
repo, or set `GOPRIVATE=gitea.dohertylan.com/*` to fetch the module
straight from the VCS — this both bypasses the public module proxy and
disables checksum-database (`sum.golang.org`) verification for that path.
Add `GOINSECURE=gitea.dohertylan.com/*` if the host serves the module over
plain HTTP rather than HTTPS.
## Releasing a new version
Go modules in monorepo subdirectories use prefixed tags. To tag a release
from this repo:
````bash
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
````
The script validates semver, refuses to tag with uncommitted tracked
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
pushes it to origin.
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
+873 -5
View File
@@ -6,6 +6,7 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
@@ -14,6 +15,7 @@ import (
"io"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
@@ -89,10 +91,26 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
case "unsubscribe-bulk":
return runUnsubscribeBulk(ctx, args[1:], stdout, stderr)
case "read-bulk":
return runReadBulk(ctx, args[1:], stdout, stderr)
case "write-bulk":
return runWriteBulk(ctx, args[1:], stdout, stderr)
case "write2-bulk":
return runWrite2Bulk(ctx, args[1:], stdout, stderr)
case "write-secured-bulk":
return runWriteSecuredBulk(ctx, args[1:], stdout, stderr)
case "write-secured2-bulk":
return runWriteSecured2Bulk(ctx, args[1:], stdout, stderr)
case "bench-read-bulk":
return runBenchReadBulk(ctx, args[1:], stdout, stderr)
case "write":
return runWrite(ctx, args[1:], stdout, stderr)
case "stream-events":
return runStreamEvents(ctx, args[1:], stdout, stderr)
case "stream-alarms":
return runStreamAlarms(ctx, args[1:], stdout, stderr)
case "acknowledge-alarm":
return runAcknowledgeAlarm(ctx, args[1:], stdout, stderr)
case "smoke":
return runSmoke(ctx, args[1:], stdout, stderr)
case "galaxy-test-connection":
@@ -103,6 +121,12 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
return runGalaxyDiscover(ctx, args[1:], stdout, stderr)
case "galaxy-watch":
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
case "galaxy-browse":
return runGalaxyBrowse(ctx, args[1:], stdout, stderr)
case "ping":
return runPing(ctx, args[1:], stdout, stderr)
case "batch":
return runBatch(ctx, os.Stdin, stdout, stderr)
default:
writeUsage(stderr)
return fmt.Errorf("unknown command %q", args[0])
@@ -208,6 +232,52 @@ func runCloseSession(ctx context.Context, args []string, stdout, stderr io.Write
return nil
}
func runPing(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("ping", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
message := flags.String("message", "ping", "ping payload message")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.PingRaw(ctx, *message)
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, commandReplyOutput{
Command: "ping",
Options: options,
Reply: mustMarshalProto(reply),
})
}
// DiagnosticMessage carries the echoed ping text set by the gateway.
// Fall back to the kind string when the gateway returns an empty message
// (forward-compat guard for future gateway versions). writeCommandOutput
// is not reused here because it would print the opaque Kind enum rather
// than the human-readable echo.
echo := reply.GetDiagnosticMessage()
if echo == "" {
echo = reply.GetKind().String()
}
fmt.Fprintln(stdout, echo)
return nil
}
func runRegister(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("register", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -337,11 +407,378 @@ func runUnsubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Wr
}
defer client.Close()
handles, err := parseInt32List(*itemHandles)
if err != nil {
return err
}
session := mxgateway.NewSessionForID(client, *sessionID)
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), parseInt32List(*itemHandles))
results, err := session.UnsubscribeBulk(ctx, int32(*serverHandle), handles)
return writeBulkOutput(stdout, *jsonOutput, "unsubscribe-bulk", options, results, err)
}
func runReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("read-bulk", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
items := flags.String("items", "", "comma-separated tag addresses")
timeoutMs := flags.Int("timeout-ms", 0, "per-tag snapshot timeout in milliseconds (0 = worker default)")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" || *items == "" {
return errors.New("session-id and items are required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
results, err := session.ReadBulk(ctx, int32(*serverHandle), parseStringList(*items), time.Duration(*timeoutMs)*time.Millisecond)
return writeReadBulkOutput(stdout, *jsonOutput, "read-bulk", options, results, err)
}
func runWriteBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-bulk", false)
}
func runWrite2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write2-bulk", true)
}
func runWriteSecuredBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured-bulk", false)
}
func runWriteSecured2Bulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWriteBulkVariant(ctx, args, stdout, stderr, "write-secured2-bulk", true)
}
// runWriteBulkVariant shares the flag-parsing + entry-build skeleton across
// the four bulk-write families. The variant is derived from command alone;
// withTimestamp adds a --timestamp-value flag. To keep wrong-variant flags
// from silently no-op'ing, secured-only flags (-current-user-id /
// -verifier-user-id) are only registered for the secured variants, and
// -user-id only for the non-secured Write/Write2 variants — a wrong-variant
// flag then surfaces as a clean "flag provided but not defined" error.
func runWriteBulkVariant(ctx context.Context, args []string, stdout, stderr io.Writer, command string, withTimestamp bool) error {
secured := command == "write-secured-bulk" || command == "write-secured2-bulk"
flags := flag.NewFlagSet(command, flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
itemHandles := flags.String("item-handles", "", "comma-separated item handles")
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
values := flags.String("values", "", "comma-separated values (one per item handle)")
var (
userID *int
currentUserID *int
verifierUserID *int
)
if secured {
currentUserID = flags.Int("current-user-id", 0, "MXAccess current user id (Secured variants)")
verifierUserID = flags.Int("verifier-user-id", 0, "MXAccess verifier user id (Secured variants)")
} else {
userID = flags.Int("user-id", 0, "MXAccess user id (Write/Write2 variants)")
}
timestampValue := flags.String("timestamp-value", "", "RFC 3339 timestamp shared across all entries (Write2/WriteSecured2 variants)")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" || *itemHandles == "" || *values == "" {
return errors.New("session-id, item-handles, and values are required")
}
handles, err := parseInt32List(*itemHandles)
if err != nil {
return err
}
valueTexts := parseStringList(*values)
if len(handles) != len(valueTexts) {
return fmt.Errorf("item-handles count (%d) does not match values count (%d)", len(handles), len(valueTexts))
}
parsedValues := make([]*mxgateway.MxValue, len(handles))
for i, text := range valueTexts {
v, err := parseValue(*valueType, text)
if err != nil {
return fmt.Errorf("entry %d: %w", i, err)
}
parsedValues[i] = v
}
var tsValue *mxgateway.MxValue
if withTimestamp {
if *timestampValue == "" {
return errors.New("timestamp-value is required for write2/write-secured2 bulk variants")
}
parsed, err := parseRfc3339Timestamp(*timestampValue)
if err != nil {
return err
}
tsValue = parsed
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
var results []*mxgateway.BulkWriteResult
switch command {
case "write-bulk":
entries := make([]*mxgateway.WriteBulkEntry, len(handles))
for i := range handles {
entries[i] = &mxgateway.WriteBulkEntry{ItemHandle: handles[i], Value: parsedValues[i], UserId: int32(*userID)}
}
results, err = session.WriteBulk(ctx, int32(*serverHandle), entries)
case "write2-bulk":
entries := make([]*mxgateway.Write2BulkEntry, len(handles))
for i := range handles {
entries[i] = &mxgateway.Write2BulkEntry{ItemHandle: handles[i], Value: parsedValues[i], TimestampValue: tsValue, UserId: int32(*userID)}
}
results, err = session.Write2Bulk(ctx, int32(*serverHandle), entries)
case "write-secured-bulk":
entries := make([]*mxgateway.WriteSecuredBulkEntry, len(handles))
for i := range handles {
entries[i] = &mxgateway.WriteSecuredBulkEntry{
ItemHandle: handles[i],
Value: parsedValues[i],
CurrentUserId: int32(*currentUserID),
VerifierUserId: int32(*verifierUserID),
}
}
results, err = session.WriteSecuredBulk(ctx, int32(*serverHandle), entries)
case "write-secured2-bulk":
entries := make([]*mxgateway.WriteSecured2BulkEntry, len(handles))
for i := range handles {
entries[i] = &mxgateway.WriteSecured2BulkEntry{
ItemHandle: handles[i],
Value: parsedValues[i],
TimestampValue: tsValue,
CurrentUserId: int32(*currentUserID),
VerifierUserId: int32(*verifierUserID),
}
}
results, err = session.WriteSecured2Bulk(ctx, int32(*serverHandle), entries)
default:
return fmt.Errorf("unsupported bulk write command %q", command)
}
return writeWriteBulkOutput(stdout, *jsonOutput, command, options, results, err)
}
// parseRfc3339Timestamp parses an RFC 3339 timestamp and returns the
// MxValue protobuf representation used for the timestamped write families.
func parseRfc3339Timestamp(text string) (*mxgateway.MxValue, error) {
t, err := time.Parse(time.RFC3339Nano, text)
if err != nil {
return nil, fmt.Errorf("invalid RFC 3339 timestamp %q: %w", text, err)
}
return mxgateway.TimestampValue(t), nil
}
// runBenchReadBulk drives the cross-language ReadBulk stress benchmark from Go:
// opens its own session, subscribes to bulk-size tags so the worker value cache
// populates from real OnDataChange events, runs ReadBulk in a tight loop for
// duration-seconds with per-call timing, and emits the shared JSON schema the
// scripts/bench-read-bulk.ps1 driver collates across all five clients.
func runBenchReadBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("bench-read-bulk", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
clientName := flags.String("client-name", "mxgw-go-bench", "session client name")
durationSeconds := flags.Int("duration-seconds", 30, "steady-state measurement window in seconds")
warmupSeconds := flags.Int("warmup-seconds", 3, "warm-up window before measurement, in seconds")
bulkSize := flags.Int("bulk-size", 6, "tags per ReadBulk call")
tagStart := flags.Int("tag-start", 1, "first machine number")
tagPrefix := flags.String("tag-prefix", "TestMachine_", "tag prefix (machine number appended as %03d)")
tagAttribute := flags.String("tag-attribute", "TestChangingInt", "attribute appended to each tag prefix")
timeoutMs := flags.Int("timeout-ms", 1500, "per-tag snapshot timeout in milliseconds")
if err := flags.Parse(args); err != nil {
return err
}
if *bulkSize < 1 {
return errors.New("bulk-size must be positive")
}
if *durationSeconds < 1 {
return errors.New("duration-seconds must be positive")
}
tags := make([]string, *bulkSize)
for i := 0; i < *bulkSize; i++ {
tags[i] = fmt.Sprintf("%s%03d.%s", *tagPrefix, *tagStart+i, *tagAttribute)
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session, err := client.OpenSession(ctx, mxgateway.OpenSessionOptions{ClientSessionName: *clientName})
if err != nil {
return err
}
defer func() {
_, _ = session.Close(context.Background())
}()
serverHandle, err := session.Register(ctx, *clientName)
if err != nil {
return err
}
subscribeResults, err := session.SubscribeBulk(ctx, serverHandle, tags)
if err != nil {
return err
}
itemHandles := make([]int32, 0, len(subscribeResults))
for _, result := range subscribeResults {
if result.GetWasSuccessful() {
itemHandles = append(itemHandles, result.GetItemHandle())
}
}
defer func() {
if len(itemHandles) > 0 {
_, _ = session.UnsubscribeBulk(context.Background(), serverHandle, itemHandles)
}
}()
// Warm-up: drive identical calls so any first-call JIT / connection-pool
// setup is amortised before the measurement window opens. The ctx.Err()
// guard short-circuits on Ctrl+C / parent-cancel instead of spinning
// failing ReadBulk calls until the wall-clock deadline elapses.
warmupDeadline := time.Now().Add(time.Duration(*warmupSeconds) * time.Second)
timeout := time.Duration(*timeoutMs) * time.Millisecond
for time.Now().Before(warmupDeadline) && ctx.Err() == nil {
_, _ = session.ReadBulk(ctx, serverHandle, tags, timeout)
}
// Steady state: per-call latency captured via time.Now() deltas.
latenciesMs := make([]float64, 0, 65536)
var totalReadResults int64
var cachedReadResults int64
var successfulCalls, failedCalls int
steadyStart := time.Now()
steadyDeadline := steadyStart.Add(time.Duration(*durationSeconds) * time.Second)
for time.Now().Before(steadyDeadline) && ctx.Err() == nil {
callStart := time.Now()
results, err := session.ReadBulk(ctx, serverHandle, tags, timeout)
elapsed := time.Since(callStart)
latenciesMs = append(latenciesMs, float64(elapsed.Nanoseconds())/1e6)
if err != nil {
failedCalls++
continue
}
successfulCalls++
for _, r := range results {
totalReadResults++
if r.GetWasCached() {
cachedReadResults++
}
}
}
steadyElapsed := time.Since(steadyStart)
totalCalls := successfulCalls + failedCalls
callsPerSecond := 0.0
if steadyElapsed.Seconds() > 0 {
callsPerSecond = float64(totalCalls) / steadyElapsed.Seconds()
}
stats := map[string]any{
"language": "go",
"command": "bench-read-bulk",
"endpoint": options.Endpoint,
"clientName": *clientName,
"bulkSize": *bulkSize,
"durationSeconds": *durationSeconds,
"warmupSeconds": *warmupSeconds,
"durationMs": steadyElapsed.Milliseconds(),
"tags": tags,
"totalCalls": totalCalls,
"successfulCalls": successfulCalls,
"failedCalls": failedCalls,
"totalReadResults": totalReadResults,
"cachedReadResults": cachedReadResults,
"callsPerSecond": roundTo(callsPerSecond, 2),
"latencyMs": percentileSummary(latenciesMs),
}
if *jsonOutput {
return writeJSON(stdout, stats)
}
fmt.Fprintln(stdout, callsPerSecond)
return nil
}
// percentileSummary returns the same { p50, p95, p99, max, mean } shape every
// language bench emits, rounded to 3 decimal places so the PowerShell driver
// sees one schema across all five clients.
func percentileSummary(sample []float64) map[string]float64 {
if len(sample) == 0 {
return map[string]float64{"p50": 0, "p95": 0, "p99": 0, "max": 0, "mean": 0}
}
sorted := append([]float64(nil), sample...)
sort.Float64s(sorted)
mean := 0.0
maxValue := sorted[len(sorted)-1]
for _, v := range sample {
mean += v
}
mean /= float64(len(sample))
return map[string]float64{
"p50": roundTo(percentile(sorted, 0.50), 3),
"p95": roundTo(percentile(sorted, 0.95), 3),
"p99": roundTo(percentile(sorted, 0.99), 3),
"max": roundTo(maxValue, 3),
"mean": roundTo(mean, 3),
}
}
// percentile uses nearest-rank with linear interpolation; matches the .NET
// implementation so cross-language comparisons are apples-to-apples.
func percentile(sorted []float64, quantile float64) float64 {
if len(sorted) == 0 {
return 0
}
if len(sorted) == 1 {
return sorted[0]
}
rank := quantile * float64(len(sorted)-1)
lower := int(rank)
upper := lower + 1
if upper >= len(sorted) {
return sorted[lower]
}
fraction := rank - float64(lower)
return sorted[lower] + (sorted[upper]-sorted[lower])*fraction
}
func roundTo(value float64, digits int) float64 {
shift := 1.0
for i := 0; i < digits; i++ {
shift *= 10
}
return float64(int64(value*shift+0.5)) / shift
}
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("write", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -428,6 +865,119 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
return nil
}
func runStreamAlarms(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("stream-alarms", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
filterPrefix := flags.String("filter-prefix", "", "alarm-reference prefix scoping the feed; empty means unscoped")
limit := flags.Int("limit", 0, "maximum feed messages to read; 0 means unbounded")
if err := flags.Parse(args); err != nil {
return err
}
client, _, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
// Mirror runStreamEvents so Ctrl+C on a long-running stream-alarms command
// cancels the gRPC stream cleanly (the gateway sees codes.Canceled rather
// than a torn TCP connection) and the deferred client.Close() actually runs.
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stopSignals()
streamCtx, cancelStream := context.WithCancel(signalCtx)
defer cancelStream()
stream, err := client.StreamAlarms(streamCtx, &mxgateway.StreamAlarmsRequest{AlarmFilterPrefix: *filterPrefix})
if err != nil {
return err
}
count := 0
for {
message, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
if *jsonOutput {
fmt.Fprintln(stdout, string(mustMarshalProto(message)))
} else {
fmt.Fprintln(stdout, formatAlarmFeedMessage(message))
}
count++
if *limit > 0 && count >= *limit {
cancelStream()
return nil
}
}
}
// formatAlarmFeedMessage renders one AlarmFeedMessage in the CLI's plain-text
// output style, distinguishing the active-alarm snapshot, snapshot-complete
// sentinel, and transition cases of the message's payload oneof.
func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
switch {
case message.GetActiveAlarm() != nil:
alarm := message.GetActiveAlarm()
return fmt.Sprintf("active-alarm %s state=%s severity=%d", alarm.GetAlarmFullReference(), alarm.GetCurrentState(), alarm.GetSeverity())
case message.GetSnapshotComplete():
return "snapshot-complete"
case message.GetTransition() != nil:
transition := message.GetTransition()
return fmt.Sprintf("transition %s kind=%s severity=%d", transition.GetAlarmFullReference(), transition.GetTransitionKind(), transition.GetSeverity())
default:
return "unknown"
}
}
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
reference := flags.String("reference", "", "full alarm reference to acknowledge")
comment := flags.String("comment", "", "operator acknowledge comment")
operator := flags.String("operator", "", "operator user performing the acknowledge")
if err := flags.Parse(args); err != nil {
return err
}
if *reference == "" {
return errors.New("reference is required")
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
reply, err := client.AcknowledgeAlarm(ctx, &mxgateway.AcknowledgeAlarmRequest{
AlarmFullReference: *reference,
Comment: *comment,
OperatorUser: *operator,
})
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, commandReplyOutput{
Command: "acknowledge-alarm",
Options: options,
Reply: mustMarshalProto(reply),
})
}
fmt.Fprintln(stdout, reply.GetHresult())
return nil
}
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -514,7 +1064,7 @@ func parseStringList(value string) []string {
return items
}
func parseInt32List(value string) []int32 {
func parseInt32List(value string) ([]int32, error) {
parts := strings.Split(value, ",")
items := make([]int32, 0, len(parts))
for _, part := range parts {
@@ -524,11 +1074,11 @@ func parseInt32List(value string) []int32 {
}
parsed, err := strconv.ParseInt(item, 10, 32)
if err != nil {
panic(err)
return nil, fmt.Errorf("invalid item handle %q: %w", item, err)
}
items = append(items, int32(parsed))
}
return items
return items, nil
}
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
@@ -647,6 +1197,36 @@ func writeBulkOutput(stdout io.Writer, jsonOutput bool, command string, options
return nil
}
func writeWriteBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.BulkWriteResult, err error) error {
if err != nil {
return err
}
if jsonOutput {
return writeJSON(stdout, map[string]any{
"command": command,
"options": options,
"results": results,
})
}
fmt.Fprintln(stdout, len(results))
return nil
}
func writeReadBulkOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, results []*mxgateway.BulkReadResult, err error) error {
if err != nil {
return err
}
if jsonOutput {
return writeJSON(stdout, map[string]any{
"command": command,
"options": options,
"results": results,
})
}
fmt.Fprintln(stdout, len(results))
return nil
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
@@ -666,7 +1246,67 @@ type protojsonMessage interface {
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch>")
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
}
// batchEOR is the end-of-result sentinel emitted to stdout after every command
// in batch mode, regardless of success or failure.
const batchEOR = "__MXGW_BATCH_EOR__"
// runBatch reads one command line at a time from in, dispatches each via the
// normal runWithIO routing, and writes a batchEOR sentinel to stdout after
// every result. Errors are serialised as JSON to stdout (not stderr) so the
// harness can parse them without interleaving stderr. Blank lines are
// skipped; only stdin EOF ends the session.
//
// The scanner buffer is widened to 16 MiB so a single long command line
// (e.g. a bulk-write with several thousand handles) does not trip the
// default 64 KiB bufio.Scanner token-too-long error and abort the session.
// If a line still exceeds the cap, the error is surfaced as a per-command
// error-with-sentinel and the session continues.
func runBatch(ctx context.Context, in io.Reader, stdout, stderr io.Writer) error {
bw := bufio.NewWriter(stdout)
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for {
if !scanner.Scan() {
break
}
line := scanner.Text()
args := strings.Fields(line)
if len(args) == 0 {
// Skip blank / whitespace-only lines; do NOT terminate. The
// session ends only on stdin EOF so a stray blank line in a
// PowerShell here-string does not silently drop later commands.
continue
}
if err := runWithIO(ctx, args, bw, stderr); err != nil {
// Write error as JSON to stdout (bw) so the harness sees it in the
// same stream as normal output, framed by the EOR sentinel.
errPayload := map[string]string{
"error": err.Error(),
"type": "error",
}
_ = writeJSON(bw, errPayload)
}
_, _ = fmt.Fprintln(bw, batchEOR)
_ = bw.Flush()
}
if err := scanner.Err(); err != nil {
// Emit the scanner failure as a final error-with-sentinel so the
// harness sees the failure framed, then return the error so the
// process exit reflects it. This handles bufio.ErrTooLong for any
// pathological line above the 16 MiB cap.
errPayload := map[string]string{
"error": err.Error(),
"type": "error",
}
_ = writeJSON(bw, errPayload)
_, _ = fmt.Fprintln(bw, batchEOR)
_ = bw.Flush()
return err
}
return nil
}
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
@@ -869,6 +1509,234 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
}
}
// runGalaxyBrowse drives the lazy-browse Galaxy helper from the CLI. Without
// -parent it walks the root objects via GalaxyClient.Browse and eagerly expands
// -depth further levels (each level reuses the same BrowseChildrenOptions, like
// the library helper). With -parent it fetches exactly one level of children for
// that gobject id via a parent-scoped BrowseChildren request; -depth is not
// meaningful there and a warning is emitted if combined, mirroring the Rust CLI.
//
// Filter flags map onto BrowseChildrenOptions: -category-ids and
// -template-contains are comma-separated lists (matching this CLI's other
// list-valued flags), -tag-name-glob / -alarm-bearing-only / -historized-only
// are scalar, and -include-attributes is a tri-state pointer (left nil unless
// the flag is provided so the server default applies).
func runGalaxyBrowse(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-browse", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
parent := flags.Int("parent", -1, "parent gobject id whose children to browse; omit (or <0) for root objects")
depth := flags.Int("depth", 0, "additional levels to eagerly expand beneath each root node; ignored with -parent")
categoryIDs := flags.String("category-ids", "", "comma-separated Galaxy category ids to restrict results")
templateContains := flags.String("template-contains", "", "comma-separated template tag names the chain must contain")
tagNameGlob := flags.String("tag-name-glob", "", "restrict to objects whose tag name matches this glob")
alarmBearingOnly := flags.Bool("alarm-bearing-only", false, "restrict to alarm-bearing objects")
historizedOnly := flags.Bool("historized-only", false, "restrict to historized objects")
includeAttributes := flags.Bool("include-attributes", false, "populate attributes on returned objects (overrides server default)")
if err := flags.Parse(args); err != nil {
return err
}
categoryList, err := parseInt32List(*categoryIDs)
if err != nil {
return err
}
opts := &mxgateway.BrowseChildrenOptions{
CategoryIds: categoryList,
TemplateChainContains: parseStringList(*templateContains),
TagNameGlob: *tagNameGlob,
AlarmBearingOnly: *alarmBearingOnly,
HistorizedOnly: *historizedOnly,
}
// Only override the server default when the flag was actually set; the
// pointer form mirrors the proto's optional field.
flags.Visit(func(f *flag.Flag) {
if f.Name == "include-attributes" {
value := *includeAttributes
opts.IncludeAttributes = &value
}
})
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
// A specific parent → one level of children via the raw parent-scoped RPC.
if *parent >= 0 {
if *parent == 0 {
fmt.Fprintln(stderr, "warning: -parent 0 is the server root sentinel; omit -parent for the root walk, or use -parent <id> >= 1")
}
if *depth > 0 {
fmt.Fprintln(stderr, "warning: -depth is ignored when -parent is specified")
}
return runGalaxyBrowseParent(ctx, client, int32(*parent), opts, stdout, *jsonOutput, options)
}
// No parent → walk the lazy-browse tree from the root objects, eagerly
// expanding -depth further levels so the print walks cached children
// without re-issuing RPCs.
nodes, err := client.Browse(ctx, opts)
if err != nil {
return err
}
for _, node := range nodes {
if err := expandToDepth(ctx, node, *depth); err != nil {
return err
}
}
if *jsonOutput {
jsonNodes := make([]map[string]any, 0, len(nodes))
for _, node := range nodes {
jsonNodes = append(jsonNodes, lazyNodeToJSON(node))
}
return writeJSON(stdout, map[string]any{
"command": "galaxy-browse",
"options": options,
"nodes": jsonNodes,
})
}
fmt.Fprintln(stdout, len(nodes))
for _, node := range nodes {
printLazyNode(stdout, node, 0)
}
return nil
}
// runGalaxyBrowseParent fetches exactly one level of children for parentID via a
// parent-scoped BrowseChildren request, paging until the server stops. It does
// not lazily wrap the children in nodes; the single level is rendered directly.
func runGalaxyBrowseParent(
ctx context.Context,
client *mxgateway.GalaxyClient,
parentID int32,
opts *mxgateway.BrowseChildrenOptions,
stdout io.Writer,
jsonOutput bool,
options commonOptions,
) error {
var children []*mxgateway.GalaxyObject
pageToken := ""
seen := map[string]struct{}{}
for {
req := &mxgateway.BrowseChildrenRequest{
PageSize: browseChildrenCLIPageSize,
PageToken: pageToken,
CategoryIds: opts.CategoryIds,
TemplateChainContains: opts.TemplateChainContains,
TagNameGlob: opts.TagNameGlob,
AlarmBearingOnly: opts.AlarmBearingOnly,
HistorizedOnly: opts.HistorizedOnly,
IncludeAttributes: opts.IncludeAttributes,
Parent: &mxgateway.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: parentID},
}
reply, err := client.BrowseChildrenRaw(ctx, req)
if err != nil {
return err
}
children = append(children, reply.GetChildren()...)
pageToken = reply.GetNextPageToken()
if pageToken == "" {
break
}
if _, dup := seen[pageToken]; dup {
return fmt.Errorf("galaxy browse children returned repeated page token %q", pageToken)
}
seen[pageToken] = struct{}{}
}
if jsonOutput {
jsonChildren := make([]map[string]any, 0, len(children))
for _, child := range children {
jsonChildren = append(jsonChildren, galaxyObjectToJSON(child))
}
return writeJSON(stdout, map[string]any{
"command": "galaxy-browse",
"options": options,
"parentId": parentID,
"children": jsonChildren,
})
}
fmt.Fprintln(stdout, len(children))
for _, child := range children {
fmt.Fprintf(stdout, "%d\t%s\t%s\t(attrs=%d)\n", child.GetGobjectId(), child.GetTagName(), child.GetBrowseName(), len(child.GetAttributes()))
}
return nil
}
// browseChildrenCLIPageSize is the per-request page size for the -parent
// single-level walk. It mirrors the library's browseChildrenPageSize so the
// CLI and the lazy-browse helper page identically.
const browseChildrenCLIPageSize = 500
// expandToDepth eagerly expands node and remaining further levels beneath it so
// a subsequent print walk reads cached children without re-issuing RPCs. A
// remaining of 0 leaves the node unexpanded (only the requested level prints).
func expandToDepth(ctx context.Context, node *mxgateway.LazyBrowseNode, remaining int) error {
if remaining <= 0 {
return nil
}
if err := node.Expand(ctx); err != nil {
return err
}
for _, child := range node.Children() {
if err := expandToDepth(ctx, child, remaining-1); err != nil {
return err
}
}
return nil
}
// printLazyNode renders one node and its already-expanded children as an
// indent-per-level tree. Only children loaded by a prior Expand are walked.
func printLazyNode(stdout io.Writer, node *mxgateway.LazyBrowseNode, level int) {
indent := strings.Repeat(" ", level)
obj := node.Object()
fmt.Fprintf(stdout, "%s%d\t%s\t%s\t(attrs=%d, hasChildrenHint=%t)\n",
indent, obj.GetGobjectId(), obj.GetTagName(), obj.GetBrowseName(), len(obj.GetAttributes()), node.HasChildrenHint())
for _, child := range node.Children() {
printLazyNode(stdout, child, level+1)
}
}
// lazyNodeToJSON renders one lazy node and its already-expanded children as a
// nested JSON object.
func lazyNodeToJSON(node *mxgateway.LazyBrowseNode) map[string]any {
out := galaxyObjectToJSON(node.Object())
out["hasChildrenHint"] = node.HasChildrenHint()
children := node.Children()
jsonChildren := make([]map[string]any, 0, len(children))
for _, child := range children {
jsonChildren = append(jsonChildren, lazyNodeToJSON(child))
}
out["children"] = jsonChildren
return out
}
// galaxyObjectToJSON renders the scalar fields of a GalaxyObject for the
// browse JSON output. Attributes are summarised by count to keep the tree
// compact; -include-attributes still drives whether the server populates them.
func galaxyObjectToJSON(obj *mxgateway.GalaxyObject) map[string]any {
return map[string]any{
"gobjectId": obj.GetGobjectId(),
"tagName": obj.GetTagName(),
"containedName": obj.GetContainedName(),
"browseName": obj.GetBrowseName(),
"parentGobjectId": obj.GetParentGobjectId(),
"isArea": obj.GetIsArea(),
"categoryId": obj.GetCategoryId(),
"templateChain": obj.GetTemplateChain(),
"attributeCount": len(obj.GetAttributes()),
}
}
func formatDeployEvent(event *mxgateway.DeployEvent) string {
observed := ""
if ts := event.GetObservedAt(); ts != nil {
+481
View File
@@ -2,9 +2,15 @@ package main
import (
"bytes"
"context"
"encoding/json"
"net"
"strings"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
)
func TestRunVersionJSON(t *testing.T) {
@@ -47,6 +53,34 @@ func TestCommonOptionsRedactsAPIKey(t *testing.T) {
}
}
func TestRunBatchEmitsEORAfterVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
in := strings.NewReader("version --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "\n"+batchEOR+"\n") && !strings.HasSuffix(out, batchEOR+"\n") {
t.Fatalf("expected EOR marker %q in stdout; got: %q", batchEOR, out)
}
idx := strings.Index(out, batchEOR)
if idx <= 0 {
t.Fatalf("EOR marker not found or appeared before any output: %q", out)
}
payload := out[:idx]
var output versionOutput
if err := json.Unmarshal([]byte(payload), &output); err != nil {
t.Fatalf("parse JSON block before EOR: %v (payload=%q)", err, payload)
}
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
t.Fatalf("protocol versions were not populated: %+v", output)
}
}
func TestParseValueBuildsTypedValue(t *testing.T) {
value, err := parseValue("int32", "123")
if err != nil {
@@ -56,3 +90,450 @@ func TestParseValueBuildsTypedValue(t *testing.T) {
t.Fatalf("int32 value = %d, want 123", got)
}
}
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
// secured-only flags must be unavailable on non-secured variants, and
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
// but not defined" error instead of silently no-op'ing.
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
cases := []struct {
name string
args []string
}{
{
name: "write-bulk-rejects-current-user-id",
args: []string{"write-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-bulk-rejects-verifier-user-id",
args: []string{"write-bulk", "-verifier-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write2-bulk-rejects-current-user-id",
args: []string{"write2-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-secured-bulk-rejects-user-id",
args: []string{"write-secured-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-secured2-bulk-rejects-user-id",
args: []string{"write-secured2-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(%v) returned no error", tc.args)
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("runWithIO(%v) error = %v; want 'flag provided but not defined'", tc.args, err)
}
})
}
}
// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
// driver) short-circuits the bench instead of spinning failing ReadBulk
// calls until the wall-clock deadline elapses.
func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
fake := &benchFakeGateway{}
pb.RegisterMxAccessGatewayServer(server, fake)
go func() {
_ = server.Serve(listener)
}()
defer server.Stop()
defer listener.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Long warm-up + duration, so if the ctx.Err() guard were missing the
// loops would run for ~10s. With the guard, the cancel below short-
// circuits both loops within ~one ReadBulk iteration.
args := []string{
"bench-read-bulk",
"-endpoint", listener.Addr().String(),
"-plaintext",
"-api-key", "test",
"-warmup-seconds", "5",
"-duration-seconds", "5",
"-bulk-size", "1",
"-timeout-ms", "100",
}
// Cancel after a brief delay — far less than warmup+duration (10s).
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
var stdout, stderr bytes.Buffer
start := time.Now()
err = runWithIO(ctx, args, &stdout, &stderr)
elapsed := time.Since(start)
// With the ctx.Err() guard, the loops exit well before the wall-clock
// deadlines (warmup=5s + duration=5s = 10s). Allow generous slack for
// CI noise but assert clearly less than the un-guarded worst case.
if elapsed > 4*time.Second {
t.Fatalf("bench-read-bulk took %s after ctx cancel; want <4s (ctx.Err() guard missing?). err=%v stderr=%s", elapsed, err, stderr.String())
}
}
// TestRunPingPlainText verifies the ping subcommand round-trips through the
// fake gateway and prints the echo (diagnostic_message) in plain-text mode.
func TestRunPingPlainText(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
fake := &pingFakeGateway{}
pb.RegisterMxAccessGatewayServer(server, fake)
go func() { _ = server.Serve(listener) }()
defer server.Stop()
defer listener.Close()
var stdout, stderr bytes.Buffer
args := []string{
"ping",
"-endpoint", listener.Addr().String(),
"-plaintext",
"-api-key", "test",
"-session-id", "test-session",
"-message", "hello",
}
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
got := strings.TrimSpace(stdout.String())
if got != "pong:hello" {
t.Fatalf("ping plain-text output = %q, want %q", got, "pong:hello")
}
}
// TestRunPingJSON verifies the ping subcommand emits valid JSON in --json mode.
func TestRunPingJSON(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
fake := &pingFakeGateway{}
pb.RegisterMxAccessGatewayServer(server, fake)
go func() { _ = server.Serve(listener) }()
defer server.Stop()
defer listener.Close()
var stdout, stderr bytes.Buffer
args := []string{
"ping",
"-endpoint", listener.Addr().String(),
"-plaintext",
"-api-key", "test",
"-session-id", "test-session",
"-message", "hello",
"-json",
}
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
var out commandReplyOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("parse JSON: %v\noutput: %s", err, stdout.String())
}
if out.Command != "ping" {
t.Fatalf("command = %q, want %q", out.Command, "ping")
}
// The fake gateway echoes "pong:<message>" in diagnostic_message; verify the
// echo appears in the serialised reply so a future regression that wired
// PingRaw to the wrong proto field would be caught here.
replyStr := string(out.Reply)
if !strings.Contains(replyStr, "pong:hello") {
t.Fatalf("ping JSON reply missing echoed message %q; reply = %s", "pong:hello", replyStr)
}
}
// TestRunPingRequiresSessionID verifies the ping subcommand rejects missing session-id.
func TestRunPingRequiresSessionID(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{"ping", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(ping without --session-id) returned no error")
}
if !strings.Contains(err.Error(), "session-id is required") {
t.Fatalf("error = %v; want 'session-id is required'", err)
}
}
// pingFakeGateway handles Invoke for MX_COMMAND_KIND_PING by echoing the
// message back in the diagnostic_message field so the CLI plain-text path
// has a deterministic, non-empty string to assert on.
type pingFakeGateway struct {
pb.UnimplementedMxAccessGatewayServer
}
func (g *pingFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
echo := "pong:" + req.GetCommand().GetPing().GetMessage()
return &pb.MxCommandReply{
SessionId: req.GetSessionId(),
Kind: pb.MxCommandKind_MX_COMMAND_KIND_PING,
DiagnosticMessage: echo,
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
// benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the
// bench-read-bulk session-setup sequence (OpenSession + Invoke for Register
// / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession).
type benchFakeGateway struct {
pb.UnimplementedMxAccessGatewayServer
}
func (g *benchFakeGateway) OpenSession(_ context.Context, _ *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
return &pb.OpenSessionReply{
SessionId: "bench-session",
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func (g *benchFakeGateway) CloseSession(_ context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
return &pb.CloseSessionReply{
SessionId: req.GetSessionId(),
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func (g *benchFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
kind := req.GetCommand().GetKind()
reply := &pb.MxCommandReply{
SessionId: req.GetSessionId(),
Kind: kind,
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}
switch kind {
case pb.MxCommandKind_MX_COMMAND_KIND_REGISTER:
reply.Payload = &pb.MxCommandReply_Register{Register: &pb.RegisterReply{ServerHandle: 1}}
case pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK:
reply.Payload = &pb.MxCommandReply_SubscribeBulk{SubscribeBulk: &pb.BulkSubscribeReply{
Results: []*pb.SubscribeResult{{ServerHandle: 1, ItemHandle: 1, WasSuccessful: true}},
}}
case pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK:
reply.Payload = &pb.MxCommandReply_ReadBulk{ReadBulk: &pb.BulkReadReply{
Results: []*pb.BulkReadResult{{ItemHandle: 1, WasSuccessful: true, WasCached: true}},
}}
case pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
reply.Payload = &pb.MxCommandReply_UnsubscribeBulk{UnsubscribeBulk: &pb.BulkSubscribeReply{}}
}
return reply, nil
}
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the Client.Go-023-adjacent
// positivity checks so they cannot drift while resolving the cancellation finding.
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{"bench-read-bulk", "-bulk-size", "0"}, &stdout, &stderr)
if err == nil || !strings.Contains(err.Error(), "bulk-size must be positive") {
t.Fatalf("bench-read-bulk -bulk-size 0 error = %v", err)
}
}
// browseFakeGalaxy implements BrowseChildren for the galaxy-browse subcommand
// tests. It returns two root objects when no parent is supplied (the first
// flagged as having children), and one child when the first root's gobject id
// is supplied as the parent. The recorded last request lets a test assert the
// CLI forwarded the parent and filter fields onto the wire.
type browseFakeGalaxy struct {
pb.UnimplementedGalaxyRepositoryServer
lastRequest *pb.BrowseChildrenRequest
}
func (g *browseFakeGalaxy) BrowseChildren(_ context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
g.lastRequest = req
if req.GetParentGobjectId() == 10 {
return &pb.BrowseChildrenReply{
Children: []*pb.GalaxyObject{
{GobjectId: 11, TagName: "Area1.Tank", BrowseName: "Tank"},
},
ChildHasChildren: []bool{false},
}, nil
}
return &pb.BrowseChildrenReply{
Children: []*pb.GalaxyObject{
{GobjectId: 10, TagName: "Area1", BrowseName: "Area1"},
{GobjectId: 20, TagName: "Area2", BrowseName: "Area2"},
},
ChildHasChildren: []bool{true, false},
}, nil
}
func startBrowseFakeGalaxy(t *testing.T) (addr string, fake *browseFakeGalaxy) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
fake = &browseFakeGalaxy{}
pb.RegisterGalaxyRepositoryServer(server, fake)
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
return listener.Addr().String(), fake
}
// TestRunGalaxyBrowseTextTree verifies the galaxy-browse subcommand issues
// BrowseChildren for the root walk, eagerly expands one level when --depth is
// set, and renders an indented tree.
func TestRunGalaxyBrowseTextTree(t *testing.T) {
addr, _ := startBrowseFakeGalaxy(t)
var stdout, stderr bytes.Buffer
args := []string{
"galaxy-browse",
"-endpoint", addr,
"-plaintext",
"-api-key", "test",
"-depth", "1",
}
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
// Both roots present; the first root's eagerly-expanded child appears
// indented beneath it.
for _, want := range []string{"Area1", "Area2", "Tank"} {
if !strings.Contains(out, want) {
t.Fatalf("galaxy-browse text output missing %q; got:\n%s", want, out)
}
}
if !strings.Contains(out, " ") {
t.Fatalf("galaxy-browse text output not indented for children; got:\n%s", out)
}
}
// TestRunGalaxyBrowseJSON verifies the galaxy-browse subcommand emits valid
// nested JSON and forwards filter options onto the BrowseChildren request.
func TestRunGalaxyBrowseJSON(t *testing.T) {
addr, fake := startBrowseFakeGalaxy(t)
var stdout, stderr bytes.Buffer
args := []string{
"galaxy-browse",
"-endpoint", addr,
"-plaintext",
"-api-key", "test",
"-depth", "1",
"-tag-name-glob", "Area%",
"-alarm-bearing-only",
"-json",
}
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("parse JSON: %v\noutput: %s", err, stdout.String())
}
if payload["command"] != "galaxy-browse" {
t.Fatalf("command = %v, want galaxy-browse", payload["command"])
}
nodes, ok := payload["nodes"].([]any)
if !ok || len(nodes) != 2 {
t.Fatalf("nodes = %v, want 2 root nodes", payload["nodes"])
}
// Filter fields must have reached the wire.
if got := fake.lastRequest.GetTagNameGlob(); got != "Area%" {
t.Fatalf("BrowseChildren TagNameGlob = %q, want %q", got, "Area%")
}
if !fake.lastRequest.GetAlarmBearingOnly() {
t.Fatalf("BrowseChildren AlarmBearingOnly = false, want true")
}
}
// TestRunGalaxyBrowseParentSingleLevel verifies that passing --parent fetches a
// single level of children for that parent via the parent-scoped request.
func TestRunGalaxyBrowseParentSingleLevel(t *testing.T) {
addr, fake := startBrowseFakeGalaxy(t)
var stdout, stderr bytes.Buffer
args := []string{
"galaxy-browse",
"-endpoint", addr,
"-plaintext",
"-api-key", "test",
"-parent", "10",
}
if err := runWithIO(t.Context(), args, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
if !strings.Contains(stdout.String(), "Tank") {
t.Fatalf("galaxy-browse -parent output missing child %q; got:\n%s", "Tank", stdout.String())
}
if got := fake.lastRequest.GetParentGobjectId(); got != 10 {
t.Fatalf("BrowseChildren ParentGobjectId = %d, want 10", got)
}
}
// TestRunBatchSkipsBlankLinesAndContinuesUntilEOF pins the Client.Go-027 fix:
// a blank line in the middle of a batch session must NOT terminate the loop —
// only stdin EOF ends the session.
func TestRunBatchSkipsBlankLinesAndContinuesUntilEOF(t *testing.T) {
var stdout, stderr bytes.Buffer
// version -> blank -> version (a stray blank line in the middle of a
// programmatic session).
in := strings.NewReader("version --json\n\nversion --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
// Both version commands must have produced a result before the EOR sentinel.
if count := strings.Count(out, batchEOR); count != 2 {
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, blank line skipped); out = %q", count, out)
}
}
// TestRunBatchHandlesLongCommandLine pins the Client.Go-026 fix: a command
// line longer than the default bufio.Scanner token size (64 KiB) must not
// abort the batch session.
func TestRunBatchHandlesLongCommandLine(t *testing.T) {
var stdout, stderr bytes.Buffer
// Build a single command line larger than 64 KiB. The command itself is
// invalid (no real session) but runBatch must still emit an EOR sentinel
// and continue to the next command rather than dropping the line on the
// floor with a bufio.ErrTooLong from the outer return.
huge := strings.Repeat("tag-with-a-reasonably-long-name-and-suffix,", 2000) + "trailing"
line := "subscribe-bulk -session-id none -items " + huge
if len(line) <= 64*1024 {
t.Fatalf("test setup error: long line length = %d, want > 64KiB", len(line))
}
in := strings.NewReader(line + "\nversion --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
// Both commands must produce an EOR sentinel — the long line should be a
// per-command error (still emitted with EOR), then the version command
// should run normally.
if count := strings.Count(out, batchEOR); count != 2 {
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
}
}
@@ -824,6 +824,260 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
return false
}
type BrowseChildrenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
//
// Types that are valid to be assigned to Parent:
//
// *BrowseChildrenRequest_ParentGobjectId
// *BrowseChildrenRequest_ParentTagName
// *BrowseChildrenRequest_ParentContainedPath
Parent isBrowseChildrenRequest_Parent `protobuf_oneof:"parent"`
// Maximum number of direct children to return. Server default 500; cap 5000.
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
// Opaque token returned by a previous BrowseChildren response. Bound to the
// cache sequence, parent selector, and the filter set; a mismatch returns
// InvalidArgument.
PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
CategoryIds []int32 `protobuf:"varint,6,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
TemplateChainContains []string `protobuf:"bytes,7,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
TagNameGlob string `protobuf:"bytes,8,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
IncludeAttributes *bool `protobuf:"varint,9,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
AlarmBearingOnly bool `protobuf:"varint,10,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
HistorizedOnly bool `protobuf:"varint,11,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenRequest) Reset() {
*x = BrowseChildrenRequest{}
mi := &file_galaxy_repository_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenRequest) ProtoMessage() {}
func (x *BrowseChildrenRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenRequest.ProtoReflect.Descriptor instead.
func (*BrowseChildrenRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{10}
}
func (x *BrowseChildrenRequest) GetParent() isBrowseChildrenRequest_Parent {
if x != nil {
return x.Parent
}
return nil
}
func (x *BrowseChildrenRequest) GetParentGobjectId() int32 {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentGobjectId); ok {
return x.ParentGobjectId
}
}
return 0
}
func (x *BrowseChildrenRequest) GetParentTagName() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentTagName); ok {
return x.ParentTagName
}
}
return ""
}
func (x *BrowseChildrenRequest) GetParentContainedPath() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentContainedPath); ok {
return x.ParentContainedPath
}
}
return ""
}
func (x *BrowseChildrenRequest) GetPageSize() int32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *BrowseChildrenRequest) GetPageToken() string {
if x != nil {
return x.PageToken
}
return ""
}
func (x *BrowseChildrenRequest) GetCategoryIds() []int32 {
if x != nil {
return x.CategoryIds
}
return nil
}
func (x *BrowseChildrenRequest) GetTemplateChainContains() []string {
if x != nil {
return x.TemplateChainContains
}
return nil
}
func (x *BrowseChildrenRequest) GetTagNameGlob() string {
if x != nil {
return x.TagNameGlob
}
return ""
}
func (x *BrowseChildrenRequest) GetIncludeAttributes() bool {
if x != nil && x.IncludeAttributes != nil {
return *x.IncludeAttributes
}
return false
}
func (x *BrowseChildrenRequest) GetAlarmBearingOnly() bool {
if x != nil {
return x.AlarmBearingOnly
}
return false
}
func (x *BrowseChildrenRequest) GetHistorizedOnly() bool {
if x != nil {
return x.HistorizedOnly
}
return false
}
type isBrowseChildrenRequest_Parent interface {
isBrowseChildrenRequest_Parent()
}
type BrowseChildrenRequest_ParentGobjectId struct {
ParentGobjectId int32 `protobuf:"varint,1,opt,name=parent_gobject_id,json=parentGobjectId,proto3,oneof"`
}
type BrowseChildrenRequest_ParentTagName struct {
ParentTagName string `protobuf:"bytes,2,opt,name=parent_tag_name,json=parentTagName,proto3,oneof"`
}
type BrowseChildrenRequest_ParentContainedPath struct {
ParentContainedPath string `protobuf:"bytes,3,opt,name=parent_contained_path,json=parentContainedPath,proto3,oneof"`
}
func (*BrowseChildrenRequest_ParentGobjectId) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentTagName) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentContainedPath) isBrowseChildrenRequest_Parent() {}
type BrowseChildrenReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Direct children matching the filter, sorted areas-first then by
// case-insensitive display name (same order as the dashboard tree).
Children []*GalaxyObject `protobuf:"bytes,1,rep,name=children,proto3" json:"children,omitempty"`
// Non-empty when another page of siblings is available.
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
// Total matching direct children of the parent (post-filter).
TotalChildCount int32 `protobuf:"varint,3,opt,name=total_child_count,json=totalChildCount,proto3" json:"total_child_count,omitempty"`
// Parallel array, indexed with `children`. True when the child has at least
// one matching descendant under the same filter set. Lets a UI choose
// whether to draw an expand triangle without an extra round trip.
ChildHasChildren []bool `protobuf:"varint,4,rep,packed,name=child_has_children,json=childHasChildren,proto3" json:"child_has_children,omitempty"`
// Cache sequence this reply was projected from. Clients may pass it back as
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
CacheSequence uint64 `protobuf:"varint,5,opt,name=cache_sequence,json=cacheSequence,proto3" json:"cache_sequence,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenReply) Reset() {
*x = BrowseChildrenReply{}
mi := &file_galaxy_repository_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenReply) ProtoMessage() {}
func (x *BrowseChildrenReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenReply.ProtoReflect.Descriptor instead.
func (*BrowseChildrenReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{11}
}
func (x *BrowseChildrenReply) GetChildren() []*GalaxyObject {
if x != nil {
return x.Children
}
return nil
}
func (x *BrowseChildrenReply) GetNextPageToken() string {
if x != nil {
return x.NextPageToken
}
return ""
}
func (x *BrowseChildrenReply) GetTotalChildCount() int32 {
if x != nil {
return x.TotalChildCount
}
return 0
}
func (x *BrowseChildrenReply) GetChildHasChildren() []bool {
if x != nil {
return x.ChildHasChildren
}
return nil
}
func (x *BrowseChildrenReply) GetCacheSequence() uint64 {
if x != nil {
return x.CacheSequence
}
return 0
}
var File_galaxy_repository_proto protoreflect.FileDescriptor
const file_galaxy_repository_proto_rawDesc = "" +
@@ -897,12 +1151,35 @@ const file_galaxy_repository_proto_rawDesc = "" +
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
"\ris_historized\x18\n" +
" \x01(\bR\fisHistorized\x12\x19\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm\"\x8c\x04\n" +
"\x15BrowseChildrenRequest\x12,\n" +
"\x11parent_gobject_id\x18\x01 \x01(\x05H\x00R\x0fparentGobjectId\x12(\n" +
"\x0fparent_tag_name\x18\x02 \x01(\tH\x00R\rparentTagName\x124\n" +
"\x15parent_contained_path\x18\x03 \x01(\tH\x00R\x13parentContainedPath\x12\x1b\n" +
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" +
"\n" +
"page_token\x18\x05 \x01(\tR\tpageToken\x12!\n" +
"\fcategory_ids\x18\x06 \x03(\x05R\vcategoryIds\x126\n" +
"\x17template_chain_contains\x18\a \x03(\tR\x15templateChainContains\x12\"\n" +
"\rtag_name_glob\x18\b \x01(\tR\vtagNameGlob\x122\n" +
"\x12include_attributes\x18\t \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
"\x12alarm_bearing_only\x18\n" +
" \x01(\bR\x10alarmBearingOnly\x12'\n" +
"\x0fhistorized_only\x18\v \x01(\bR\x0ehistorizedOnlyB\b\n" +
"\x06parentB\x15\n" +
"\x13_include_attributes\"\xfe\x01\n" +
"\x13BrowseChildrenReply\x12>\n" +
"\bchildren\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\bchildren\x12&\n" +
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12*\n" +
"\x11total_child_count\x18\x03 \x01(\x05R\x0ftotalChildCount\x12,\n" +
"\x12child_has_children\x18\x04 \x03(\bR\x10childHasChildren\x12%\n" +
"\x0ecache_sequence\x18\x05 \x01(\x04R\rcacheSequence2\xb6\x04\n" +
"\x10GalaxyRepository\x12h\n" +
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n" +
"\x0eBrowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var (
file_galaxy_repository_proto_rawDescOnce sync.Once
@@ -916,7 +1193,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
return file_galaxy_repository_proto_rawDescData
}
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_galaxy_repository_proto_goTypes = []any{
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
@@ -928,30 +1205,35 @@ var file_galaxy_repository_proto_goTypes = []any{
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
(*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
(*BrowseChildrenReply)(nil), // 11: galaxy_repository.v1.BrowseChildrenReply
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
(*wrapperspb.Int32Value)(nil), // 13: google.protobuf.Int32Value
}
var file_galaxy_repository_proto_depIdxs = []int32{
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
13, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
12, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
12, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
11, // [11:15] is the sub-list for method output_type
7, // [7:11] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
13, // [13:18] is the sub-list for method output_type
8, // [8:13] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
}
func init() { file_galaxy_repository_proto_init() }
@@ -964,13 +1246,18 @@ func file_galaxy_repository_proto_init() {
(*DiscoverHierarchyRequest_RootTagName)(nil),
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
}
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
(*BrowseChildrenRequest_ParentGobjectId)(nil),
(*BrowseChildrenRequest_ParentTagName)(nil),
(*BrowseChildrenRequest_ParentContainedPath)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
NumEnums: 0,
NumMessages: 10,
NumMessages: 12,
NumExtensions: 0,
NumServices: 1,
},
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1
// source: galaxy_repository.proto
@@ -23,6 +23,7 @@ const (
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
)
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
@@ -44,6 +45,11 @@ type GalaxyRepositoryClient interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error)
}
type galaxyRepositoryClient struct {
@@ -103,6 +109,16 @@ func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *Watc
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
func (c *galaxyRepositoryClient) BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BrowseChildrenReply)
err := c.cc.Invoke(ctx, GalaxyRepository_BrowseChildren_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility.
@@ -122,6 +138,11 @@ type GalaxyRepositoryServer interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error)
mustEmbedUnimplementedGalaxyRepositoryServer()
}
@@ -144,6 +165,9 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
}
func (UnimplementedGalaxyRepositoryServer) BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error) {
return nil, status.Error(codes.Unimplemented, "method BrowseChildren not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
@@ -230,6 +254,24 @@ func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.Se
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
func _GalaxyRepository_BrowseChildren_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BrowseChildrenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_BrowseChildren_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, req.(*BrowseChildrenRequest))
}
return interceptor(ctx, in, info, handler)
}
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -249,6 +291,10 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
},
{
MethodName: "BrowseChildren",
Handler: _GalaxyRepository_BrowseChildren_Handler,
},
},
Streams: []grpc.StreamDesc{
{
@@ -725,9 +725,10 @@ func (SessionState) EnumDescriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8}
}
// Public request shape for QueryActiveAlarms. session_id is currently unused
// (the snapshot is session-less) but reserved so a future per-session view
// can be added without a wire break.
// Public request shape for QueryActiveAlarms.
// Clients may leave `session_id` empty; the gateway currently ignores it and
// serves the session-less central-monitor cache. A future version may use it
// to scope the snapshot to one session.
type QueryActiveAlarmsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1
// source: mxaccess_gateway.proto
@@ -50,6 +50,9 @@ type MxAccessGatewayClient interface {
// reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
}
@@ -180,6 +183,9 @@ type MxAccessGatewayServer interface {
// reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
mustEmbedUnimplementedMxAccessGatewayServer()
}
+23
View File
@@ -51,3 +51,26 @@ func (c *Client) QueryActiveAlarms(ctx context.Context, req *QueryActiveAlarmsRe
return stream, nil
}
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
// snapshot), then a single snapshot-complete sentinel, then a transition for
// every subsequent raise / acknowledge / clear. It is served by the gateway's
// always-on alarm monitor — no worker session is opened — so any number of
// clients may attach.
//
// The returned stream is owned by the caller; cancel ctx to release it.
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
// stream to a sub-tree.
func (c *Client) StreamAlarms(ctx context.Context, req *StreamAlarmsRequest) (StreamAlarmsClient, error) {
if req == nil {
return nil, errors.New("mxgateway: stream alarms request is required")
}
stream, err := c.raw.StreamAlarms(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "stream alarms", Err: err}
}
return stream, nil
}
+77 -2
View File
@@ -147,8 +147,8 @@ func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
defer cleanup()
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
SessionId: "session-1",
AlarmFilterPrefix: "Tank01.",
SessionId: "session-1",
AlarmFilterPrefix: "Tank01.",
})
if err != nil {
t.Fatalf("QueryActiveAlarms() error = %v", err)
@@ -168,6 +168,66 @@ func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
}
}
func TestStreamAlarmsPassesFilterPrefixAndReceivesFeedMessages(t *testing.T) {
fake := &fakeGatewayWithAlarms{
feedMessages: []*pb.AlarmFeedMessage{
{
Payload: &pb.AlarmFeedMessage_ActiveAlarm{
ActiveAlarm: &pb.ActiveAlarmSnapshot{
AlarmFullReference: "Tank01.Level.HiHi",
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
},
},
},
{
Payload: &pb.AlarmFeedMessage_SnapshotComplete{
SnapshotComplete: true,
},
},
},
}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
AlarmFilterPrefix: "Tank01.",
})
if err != nil {
t.Fatalf("StreamAlarms() error = %v", err)
}
var received []*pb.AlarmFeedMessage
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("stream.Recv() error = %v", err)
}
received = append(received, msg)
}
if len(received) != 2 {
t.Fatalf("received count = %d, want 2", len(received))
}
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
t.Fatalf("captured filter prefix = %q", got)
}
if got := fake.streamAuth; got != "Bearer test-api-key" {
t.Fatalf("stream authorization metadata = %q", got)
}
}
func TestStreamAlarmsRejectsNilRequest(t *testing.T) {
fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
if _, err := client.StreamAlarms(context.Background(), nil); err == nil {
t.Fatal("StreamAlarms(nil) returned no error")
}
}
type fakeGatewayWithAlarms struct {
pb.UnimplementedMxAccessGatewayServer
@@ -178,6 +238,10 @@ type fakeGatewayWithAlarms struct {
queryRequest *pb.QueryActiveAlarmsRequest
activeSnapshots []*pb.ActiveAlarmSnapshot
streamRequest *pb.StreamAlarmsRequest
feedMessages []*pb.AlarmFeedMessage
streamAuth string
}
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
@@ -207,6 +271,17 @@ func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsReque
return nil
}
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
s.streamRequest = req
s.streamAuth = authorizationFromContext(stream.Context())
for _, msg := range s.feedMessages {
if err := stream.Send(msg); err != nil {
return err
}
}
return nil
}
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
+16 -4
View File
@@ -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.
+283
View File
@@ -230,6 +230,289 @@ func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
}
}
func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_WriteBulk{
WriteBulk: &pb.BulkWriteReply{
Results: []*pb.BulkWriteResult{
{ItemHandle: 10, WasSuccessful: true},
{ItemHandle: 11, WasSuccessful: true},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
entries := []*WriteBulkEntry{
{ItemHandle: 10, Value: Int32Value(7), UserId: 100},
{ItemHandle: 11, Value: Int32Value(8), UserId: 100},
}
results, err := session.WriteBulk(context.Background(), 12, entries)
if err != nil {
t.Fatalf("WriteBulk() error = %v", err)
}
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
t.Fatalf("entry count = %d, want 2", len(got))
}
}
func TestWriteBulkRejectsNilEntries(t *testing.T) {
fake := &fakeGatewayServer{}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
if _, err := session.WriteBulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteBulk(nil) returned no error")
}
if _, err := session.Write2Bulk(context.Background(), 12, nil); err == nil {
t.Fatal("Write2Bulk(nil) returned no error")
}
if _, err := session.WriteSecuredBulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteSecuredBulk(nil) returned no error")
}
if _, err := session.WriteSecured2Bulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteSecured2Bulk(nil) returned no error")
}
if _, err := session.ReadBulk(context.Background(), 12, nil, 0); err == nil {
t.Fatal("ReadBulk(nil) returned no error")
}
}
func TestBulkMethodsShortCircuitOnEmptySliceWithoutRoundTrip(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.WriteBulk(context.Background(), 12, []*WriteBulkEntry{})
if err != nil {
t.Fatalf("WriteBulk(empty) error = %v", err)
}
if len(results) != 0 {
t.Fatalf("WriteBulk(empty) results len = %d, want 0", len(results))
}
if fake.invokeRequest != nil {
t.Fatal("WriteBulk(empty) sent a round trip; expected short-circuit")
}
results2, err := session.Write2Bulk(context.Background(), 12, []*Write2BulkEntry{})
if err != nil {
t.Fatalf("Write2Bulk(empty) error = %v", err)
}
if len(results2) != 0 {
t.Fatalf("Write2Bulk(empty) results len = %d, want 0", len(results2))
}
if fake.invokeRequest != nil {
t.Fatal("Write2Bulk(empty) sent a round trip; expected short-circuit")
}
results3, err := session.WriteSecuredBulk(context.Background(), 12, []*WriteSecuredBulkEntry{})
if err != nil {
t.Fatalf("WriteSecuredBulk(empty) error = %v", err)
}
if len(results3) != 0 {
t.Fatalf("WriteSecuredBulk(empty) results len = %d, want 0", len(results3))
}
if fake.invokeRequest != nil {
t.Fatal("WriteSecuredBulk(empty) sent a round trip; expected short-circuit")
}
results4, err := session.WriteSecured2Bulk(context.Background(), 12, []*WriteSecured2BulkEntry{})
if err != nil {
t.Fatalf("WriteSecured2Bulk(empty) error = %v", err)
}
if len(results4) != 0 {
t.Fatalf("WriteSecured2Bulk(empty) results len = %d, want 0", len(results4))
}
if fake.invokeRequest != nil {
t.Fatal("WriteSecured2Bulk(empty) sent a round trip; expected short-circuit")
}
readResults, err := session.ReadBulk(context.Background(), 12, []string{}, 0)
if err != nil {
t.Fatalf("ReadBulk(empty) error = %v", err)
}
if len(readResults) != 0 {
t.Fatalf("ReadBulk(empty) results len = %d, want 0", len(readResults))
}
if fake.invokeRequest != nil {
t.Fatal("ReadBulk(empty) sent a round trip; expected short-circuit")
}
}
func TestWrite2BuildsCommandWithTimestampAndReturnsNoError(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
val := Int32Value(99)
ts := Int32Value(77)
err := session.Write2(context.Background(), 12, 34, val, ts, 100)
if err != nil {
t.Fatalf("Write2() error = %v", err)
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE2 {
t.Fatalf("command kind = %s, want WRITE2", req.GetCommand().GetKind())
}
w2 := req.GetCommand().GetWrite2()
if w2.GetServerHandle() != 12 {
t.Fatalf("server handle = %d, want 12", w2.GetServerHandle())
}
if w2.GetItemHandle() != 34 {
t.Fatalf("item handle = %d, want 34", w2.GetItemHandle())
}
if w2.GetValue().GetInt32Value() != 99 {
t.Fatalf("value int32 = %d, want 99", w2.GetValue().GetInt32Value())
}
if w2.GetTimestampValue().GetInt32Value() != 77 {
t.Fatalf("timestamp value int32 = %d, want 77", w2.GetTimestampValue().GetInt32Value())
}
if w2.GetUserId() != 100 {
t.Fatalf("user id = %d, want 100", w2.GetUserId())
}
}
func TestWrite2RawReturnsRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
reply, err := session.Write2Raw(context.Background(), 12, 34, Int32Value(1), Int32Value(0), 0)
if err != nil {
t.Fatalf("Write2Raw() error = %v", err)
}
if reply == nil {
t.Fatal("Write2Raw() returned nil reply")
}
if reply.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE2 {
t.Fatalf("reply kind = %s, want WRITE2", reply.GetKind())
}
}
func TestWrite2RejectsNilValue(t *testing.T) {
fake := &fakeGatewayServer{}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
if err := session.Write2(context.Background(), 12, 34, nil, Int32Value(0), 0); err == nil {
t.Fatal("Write2(nil value) returned no error")
}
if err := session.Write2(context.Background(), 12, 34, Int32Value(1), nil, 0); err == nil {
t.Fatal("Write2(nil timestampValue) returned no error")
}
}
func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_ReadBulk{
ReadBulk: &pb.BulkReadReply{
Results: []*pb.BulkReadResult{
{TagAddress: "Tank01.Level", WasSuccessful: true, WasCached: true},
{TagAddress: "Tank02.Level", WasSuccessful: true, WasCached: false},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level", "Tank02.Level"}, 250*time.Millisecond)
if err != nil {
t.Fatalf("ReadBulk() error = %v", err)
}
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
if !results[0].GetWasCached() || results[1].GetWasCached() {
t.Fatalf("WasCached flags = [%v %v], want [true false]", results[0].GetWasCached(), results[1].GetWasCached())
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetReadBulk().GetTimeoutMs(); got != 250 {
t.Fatalf("timeout ms = %d, want 250", got)
}
}
func TestReadBulkSaturatesTimeoutAboveMaxUint32(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
// 100 days in milliseconds exceeds MaxUint32 (~49.7 days).
hugeTimeout := 100 * 24 * time.Hour
_, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level"}, hugeTimeout)
if err != nil {
t.Fatalf("ReadBulk() error = %v", err)
}
got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs()
if got != ^uint32(0) {
t.Fatalf("timeout ms = %d, want %d (MaxUint32)", got, ^uint32(0))
}
}
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
hresult := int32(-2147467259)
fake := &fakeGatewayServer{
+59
View File
@@ -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)")
}
}
+246 -8
View File
@@ -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,15 @@ 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
// BrowseChildrenRequest_ParentGobjectId selects the parent-by-gobject-id
// variant of the BrowseChildrenRequest parent oneof. Exposed so callers
// (e.g. the mxgw-go CLI) can issue a parent-scoped single-level browse
// without reaching into the generated package.
BrowseChildrenRequest_ParentGobjectId = pb.BrowseChildrenRequest_ParentGobjectId //nolint:revive,staticcheck // mirrors generated proto oneof name
)
// RawDeployEventStream is the generated WatchDeployEvents client stream.
@@ -146,16 +165,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 +276,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 {
+448 -11
View File
@@ -4,11 +4,14 @@ import (
"context"
"errors"
"net"
"sync"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -55,8 +58,8 @@ func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{
Present: true,
TimeOfLastDeploy: timestamppb.New(want),
Present: true,
TimeOfLastDeploy: timestamppb.New(want),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
@@ -144,6 +147,47 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
}
}
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
page1 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 1, TagName: "A"},
{GobjectId: 2, TagName: "B"},
},
NextPageToken: "page-2",
TotalObjectCount: 3,
}
page2 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 3, TagName: "C"},
},
TotalObjectCount: 3,
}
fake := &fakeGalaxyServer{
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objs, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy: %v", err)
}
if got, want := len(objs), 3; got != want {
t.Fatalf("len(objs) = %d, want %d", got, want)
}
if len(fake.discoverHierarchyCalls) != 2 {
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
}
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
t.Fatalf("first call PageSize = %d, want %d",
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
}
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
t.Fatalf("second call page token = %q, want %q",
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
}
}
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
fake := &fakeGalaxyServer{failTest: true}
client, cleanup := newGalaxyBufconnClient(t, fake)
@@ -370,15 +414,20 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
browseChildrenCalls []*pb.BrowseChildrenRequest
browseChildrenReplies []*pb.BrowseChildrenReply
browseChildrenError error
}
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
@@ -400,6 +449,12 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas
}
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
if len(s.discoverHierarchyReplies) > 0 {
reply := s.discoverHierarchyReplies[0]
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
return reply, nil
}
if s.discoverReply != nil {
return s.discoverReply, nil
}
@@ -425,3 +480,385 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
}
return nil
}
func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
s.browseChildrenCalls = append(s.browseChildrenCalls, req)
if s.browseChildrenError != nil {
err := s.browseChildrenError
s.browseChildrenError = nil
return nil, err
}
if len(s.browseChildrenReplies) == 0 {
return &pb.BrowseChildrenReply{}, nil
}
reply := s.browseChildrenReplies[0]
s.browseChildrenReplies = s.browseChildrenReplies[1:]
return reply, nil
}
func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
return &pb.GalaxyObject{
GobjectId: id,
TagName: tag,
BrowseName: tag,
IsArea: isArea,
}
}
func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
return &pb.BrowseChildrenReply{
TotalChildCount: int32(len(children)),
CacheSequence: seq,
Children: children,
ChildHasChildren: hasChildren,
}
}
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
[]bool{true, false},
7,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if got, want := len(roots), 2; got != want {
t.Fatalf("len(roots) = %d, want %d", got, want)
}
if roots[0].Object().GetTagName() != "Plant" {
t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
}
if !roots[0].HasChildrenHint() {
t.Fatal("roots[0].HasChildrenHint = false, want true")
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true, want false")
}
if roots[1].HasChildrenHint() {
t.Fatal("roots[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
if fake.browseChildrenCalls[0].GetParent() != nil {
t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
}
}
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
[]bool{true, false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
plant := roots[0]
if plant.IsExpanded() {
t.Fatal("plant.IsExpanded = true before Expand, want false")
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
if !plant.IsExpanded() {
t.Fatal("plant.IsExpanded = false after Expand, want true")
}
children := plant.Children()
if len(children) != 2 {
t.Fatalf("len(children) = %d, want 2", len(children))
}
if children[0].Object().GetTagName() != "Area1" {
t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
}
if !children[0].HasChildrenHint() {
t.Fatal("children[0].HasChildrenHint = false, want true")
}
if children[1].HasChildrenHint() {
t.Fatal("children[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 2 {
t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
}
parent := fake.browseChildrenCalls[1].GetParent()
parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
if !ok {
t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
}
if parentGobj.ParentGobjectId != 1 {
t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
}
}
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true)},
[]bool{false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
plant := roots[0]
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #1: %v", err)
}
callsAfterFirst := len(fake.browseChildrenCalls)
if callsAfterFirst != 2 {
t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #2: %v", err)
}
if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
}
}
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
},
browseChildrenError: status.Error(codes.NotFound, "parent not found"),
}
// The first Browse() consumes the first reply; the next call (Expand) will
// then hit browseChildrenError. We need the error to fire only on the second
// call, so seed the reply first and let the call sequence consume them in
// order. Because BrowseChildren in the fake consumes browseChildrenError
// before falling through to replies, swap the strategy: keep the root reply
// but have BrowseChildren return the error on the second call. We do this by
// emptying the reply list after the first Browse.
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
// First call returns the error (because browseChildrenError takes precedence).
// To avoid that, clear it for the root call by performing a manual setup: we
// pre-stage replies first, then set the error after the first call. Easiest:
// pre-Browse() with error=nil, then set error before Expand.
fake.browseChildrenError = nil
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")
err = roots[0].Expand(context.Background())
if err == nil {
t.Fatal("Expand: error = nil, want NotFound")
}
if status.Code(err) != codes.NotFound {
t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
}
}
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
firstPage := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
7,
)
pageA := buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
[]bool{false, false},
7,
)
pageA.NextPageToken = "7:abc:2"
pageB := buildBrowseReply(
[]*pb.GalaxyObject{obj(12, "Child3", false)},
[]bool{false},
7,
)
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if err := roots[0].Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
children := roots[0].Children()
if len(children) != 3 {
t.Fatalf("len(children) = %d, want 3", len(children))
}
if len(fake.browseChildrenCalls) != 3 {
t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
}
if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
}
}
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(nil, nil, 1),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
include := true
opts := &BrowseChildrenOptions{
CategoryIds: []int32{7, 9},
TemplateChainContains: []string{"$AppObject"},
TagNameGlob: "Tank*",
IncludeAttributes: &include,
AlarmBearingOnly: true,
HistorizedOnly: true,
}
if _, err := client.Browse(context.Background(), opts); err != nil {
t.Fatalf("Browse: %v", err)
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
got := fake.browseChildrenCalls[0]
if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
}
if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
}
if got.GetTagNameGlob() != "Tank*" {
t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
}
if !got.GetIncludeAttributes() {
t.Fatal("IncludeAttributes = false, want true")
}
if !got.GetAlarmBearingOnly() {
t.Fatal("AlarmBearingOnly = false, want true")
}
if !got.GetHistorizedOnly() {
t.Fatal("HistorizedOnly = false, want true")
}
}
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
// roots
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
// one expand: one child
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx := context.Background()
roots, err := client.Browse(ctx, nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
var wg sync.WaitGroup
errs := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errs <- roots[0].Expand(ctx)
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent Expand: %v", err)
}
}
if !roots[0].IsExpanded() {
t.Fatal("IsExpanded() = false after 10 concurrent expands")
}
if got, want := len(roots[0].Children()), 1; got != want {
t.Fatalf("len(children) = %d, want %d", got, want)
}
// 1 roots fetch + exactly 1 expand fetch.
if got, want := len(fake.browseChildrenCalls), 2; got != want {
t.Fatalf("RPC count = %d, want %d", got, want)
}
}
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
// will request a second page. Queue the same reply twice so the second response
// returns the same page token, triggering the duplicate-token guard.
page := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
)
page.NextPageToken = "1:abc:1"
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.Browse(context.Background(), nil)
if err == nil {
t.Fatal("Browse: error = nil, want repeated-page-token error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
}
}
+26
View File
@@ -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
+208
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"sync"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc/codes"
@@ -387,6 +388,173 @@ func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemH
return reply.GetUnsubscribeBulk().GetResults(), nil
}
// WriteBulk invokes MXAccess Write sequentially for each entry inside one gateway command.
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
// never returns an error for per-entry MXAccess failures (it returns an error only for
// protocol-level failures or transport errors).
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write bulk entries are required")
}
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
Payload: &pb.MxCommand_WriteBulk{
WriteBulk: &pb.WriteBulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWriteBulk().GetResults(), nil
}
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write2 bulk entries are required")
}
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
Payload: &pb.MxCommand_Write2Bulk{
Write2Bulk: &pb.Write2BulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWrite2Bulk().GetResults(), nil
}
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write-secured bulk entries are required")
}
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
Payload: &pb.MxCommand_WriteSecuredBulk{
WriteSecuredBulk: &pb.WriteSecuredBulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWriteSecuredBulk().GetResults(), nil
}
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
}
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
Payload: &pb.MxCommand_WriteSecured2Bulk{
WriteSecured2Bulk: &pb.WriteSecured2BulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWriteSecured2Bulk().GetResults(), nil
}
// ReadBulk snapshots the current value of each requested tag.
//
// MXAccess COM has no synchronous Read; the worker satisfies this by returning the
// most recent cached OnDataChange value when the tag is already advised (WasCached=true),
// or by taking a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
//
// A non-nil but empty tagAddresses slice is treated as a no-op and returns an empty
// result without a wire round-trip; pass nil to surface a clear "tag addresses are
// required" error.
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
if tagAddresses == nil {
return nil, errors.New("mxgateway: tag addresses are required")
}
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
return nil, err
}
if len(tagAddresses) == 0 {
return []*BulkReadResult{}, nil
}
var timeoutMs uint32
if timeout > 0 {
ms := timeout.Milliseconds()
if ms > int64(^uint32(0)) {
timeoutMs = ^uint32(0)
} else {
timeoutMs = uint32(ms)
}
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
Payload: &pb.MxCommand_ReadBulk{
ReadBulk: &pb.ReadBulkCommand{
ServerHandle: serverHandle,
TagAddresses: tagAddresses,
TimeoutMs: timeoutMs,
},
},
})
if err != nil {
return nil, err
}
return reply.GetReadBulk().GetResults(), nil
}
// Write invokes MXAccess Write.
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
@@ -412,6 +580,46 @@ func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32,
})
}
// PingRaw sends a diagnostic PING command and returns the raw reply.
// The message is echoed back by the gateway in the reply's DiagnosticMessage field.
func (s *Session) PingRaw(ctx context.Context, message string) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_PING,
Payload: &pb.MxCommand_Ping{
Ping: &pb.PingCommand{Message: message},
},
})
}
// Write2 invokes MXAccess Write2 (timestamped single-item write).
func (s *Session) Write2(ctx context.Context, serverHandle, itemHandle int32, value, timestampValue *MxValue, userID int32) error {
_, err := s.Write2Raw(ctx, serverHandle, itemHandle, value, timestampValue, userID)
return err
}
// Write2Raw invokes MXAccess Write2 (timestamped single-item write) and returns the raw reply.
func (s *Session) Write2Raw(ctx context.Context, serverHandle, itemHandle int32, value, timestampValue *MxValue, userID int32) (*MxCommandReply, error) {
if value == nil {
return nil, errors.New("mxgateway: write2 value is required")
}
if timestampValue == nil {
return nil, errors.New("mxgateway: write2 timestamp value is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2,
Payload: &pb.MxCommand_Write2{
Write2: &pb.Write2Command{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
Value: value,
TimestampValue: timestampValue,
UserId: userID,
},
},
})
}
// Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent.
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
+35
View File
@@ -70,6 +70,32 @@ type (
WriteCommand = pb.WriteCommand
// Write2Command is the payload of an MXAccess Write2 command.
Write2Command = pb.Write2Command
// WriteBulkCommand is the payload of a bulk Write command.
WriteBulkCommand = pb.WriteBulkCommand
// WriteBulkEntry is one entry inside a WriteBulkCommand.
WriteBulkEntry = pb.WriteBulkEntry
// Write2BulkCommand is the payload of a bulk Write2 (timestamped) command.
Write2BulkCommand = pb.Write2BulkCommand
// Write2BulkEntry is one entry inside a Write2BulkCommand.
Write2BulkEntry = pb.Write2BulkEntry
// WriteSecuredBulkCommand is the payload of a bulk WriteSecured command.
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
// WriteSecuredBulkEntry is one entry inside a WriteSecuredBulkCommand.
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
// WriteSecured2BulkCommand is the payload of a bulk WriteSecured2 (timestamped) command.
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
// WriteSecured2BulkEntry is one entry inside a WriteSecured2BulkCommand.
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
// ReadBulkCommand is the payload of a bulk Read snapshot command.
ReadBulkCommand = pb.ReadBulkCommand
// BulkWriteReply aggregates BulkWriteResult entries for a bulk write command.
BulkWriteReply = pb.BulkWriteReply
// BulkWriteResult is one entry in a bulk write reply list.
BulkWriteResult = pb.BulkWriteResult
// BulkReadReply aggregates BulkReadResult entries for a bulk read command.
BulkReadReply = pb.BulkReadReply
// BulkReadResult is one entry in a bulk read reply list.
BulkReadResult = pb.BulkReadResult
// RegisterReply carries the ServerHandle returned by Register.
RegisterReply = pb.RegisterReply
// AddItemReply carries the ItemHandle returned by AddItem.
@@ -86,6 +112,11 @@ type (
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
// StreamAlarmsRequest is the gateway StreamAlarms request message.
StreamAlarmsRequest = pb.StreamAlarmsRequest
// AlarmFeedMessage is one message on the StreamAlarms feed — an
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
AlarmFeedMessage = pb.AlarmFeedMessage
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
@@ -104,6 +135,10 @@ type AlarmConditionState = pb.AlarmConditionState
// QueryActiveAlarms RPC.
type QueryActiveAlarmsClient = pb.MxAccessGateway_QueryActiveAlarmsClient
// StreamAlarmsClient is the generated server-streaming client for the
// StreamAlarms RPC.
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
// Enumerations from the generated contract re-exported for client callers.
type (
// MxCommandKind discriminates which MXAccess command an MxCommand carries.
+26 -9
View File
@@ -20,11 +20,11 @@ clients/java/
src/main/generated/
zb-mom-ww-mxgateway-client/
build.gradle
src/main/java/com/dohertylan/mxgateway/client/
src/test/java/com/dohertylan/mxgateway/client/
src/main/java/com/zb/mom/ww/mxgateway/client/
src/test/java/com/zb/mom/ww/mxgateway/client/
zb-mom-ww-mxgateway-cli/
build.gradle
src/main/java/com/dohertylan/mxgateway/cli/
src/main/java/com/zb/mom/ww/mxgateway/cli/
```
Alternative Maven layout is acceptable if the repo standardizes on Maven.
@@ -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:
@@ -192,8 +209,8 @@ stream for bounded time, and close.
Publish library and CLI separately:
- `mxgateway-client` jar,
- `mxgateway-cli` runnable distribution.
- `zb-mom-ww-mxgateway-client` jar,
- `zb-mom-ww-mxgateway-cli` runnable distribution.
Generated protobuf code should be produced during the build from shared proto
files and should not be hand-edited.
@@ -206,10 +223,10 @@ Run the Java scaffold checks from `clients/java`:
gradle test
```
The `mxgateway-client` project generates the gateway and worker protobuf/gRPC
bindings into `src/main/generated`, compiles the generated contracts, and runs
JUnit 5 tests. The `mxgateway-cli` project builds a Picocli-based `mxgw-java`
entry point for later command implementation.
The `zb-mom-ww-mxgateway-client` project generates the gateway and worker
protobuf/gRPC bindings into `src/main/generated`, compiles the generated
contracts, and runs JUnit 5 tests. The `zb-mom-ww-mxgateway-cli` project
builds a Picocli-based `mxgw-java` entry point for later command implementation.
## Related Documentation
+154 -29
View File
@@ -14,18 +14,19 @@ clients/java/
zb-mom-ww-mxgateway-cli/
```
`mxgateway-client` generates Java protobuf and gRPC sources from
`zb-mom-ww-mxgateway-client` generates Java protobuf and gRPC sources from
`../../src/ZB.MOM.WW.MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
generated sources under `src/main/generated`, which matches the client proto
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
`mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`zb-mom-ww-mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
generated stubs, and generated protobuf messages for parity tests.
`mxgateway-cli` depends on `mxgateway-client` and provides the `mxgw-java`
application entry point. The CLI supports version, session, command, event
streaming, write, and smoke-test commands with deterministic JSON output.
`zb-mom-ww-mxgateway-cli` depends on `zb-mom-ww-mxgateway-client` and provides
the `mxgw-java` application entry point. The CLI supports version, session,
command, event streaming, write, and smoke-test commands with deterministic
JSON output.
## Regenerating Protobuf Bindings
@@ -33,7 +34,7 @@ Run generation from `clients/java` after the shared `.proto` files or Java
output path changes:
```powershell
gradle :mxgateway-client:generateProto
gradle :zb-mom-ww-mxgateway-client:generateProto
```
## Client Usage
@@ -56,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
@@ -67,6 +78,12 @@ cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA.
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
yields alarm-feed messages from the gateway's central monitor), and
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
ack target). Close the subscription to cancel the underlying gRPC stream.
## Galaxy Repository Browse
The Galaxy Repository service is a separate metadata-only gRPC service exposed
@@ -98,17 +115,88 @@ try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
messages directly so callers can read all fields (including the nested
`GalaxyAttribute` list) without an extra DTO layer.
The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
`galaxy-discover`, and `galaxy-watch`. They take the same `--endpoint`,
`--api-key-env`, `--plaintext`, `--ca-file`, `--server-name-override`,
`--timeout`, and `--json` options as the gateway commands.
The CLI exposes matching subcommands: `galaxy-test-connection`,
`galaxy-last-deploy`, `galaxy-discover`, `galaxy-browse`, and `galaxy-watch`.
The short names `galaxy-test` and `galaxy-deploy-time` remain as deprecated
aliases for `galaxy-test-connection` and `galaxy-last-deploy` so existing
scripts keep working. They take the same `--endpoint`, `--api-key-env`,
`--plaintext`, `--ca-file`, `--server-name-override`, `--timeout`, and `--json`
options as the gateway commands.
```powershell
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-test-connection --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-last-deploy --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
```
`galaxy-browse` walks the hierarchy via `BrowseChildren`. Without `--parent` it
returns the root nodes and eagerly expands `--depth` further levels; with
`--parent <gobject-id>` it returns exactly one level of children for that
parent. The filter flags (`--category-ids`, `--template-contains`,
`--tag-name-glob`, `--alarm-bearing-only`, `--historized-only`,
`--include-attributes`) match `galaxy-discover`. The `--json` node shape is the
cross-client browse surface: the flattened object fields plus a
`hasChildrenHint` flag and a nested `children` array.
```powershell
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-browse --depth 1 --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
```
### Browsing lazily
For UI trees or OPC UA bridges, use `browseChildrenRaw` to walk one level at a
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
default request for root objects; subsequent calls set `parentGobjectId`,
`parentTagName`, or `parentContainedPath`. Filter fields match
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
`getChildHasChildrenList()` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics. For most callers the high-level
`browse()`/`LazyBrowseNode` walker below is the preferred surface;
`browseChildrenRaw` exposes the single underlying RPC when you need direct
control of paging.
```java
BrowseChildrenReply reply = galaxy.browseChildrenRaw(
BrowseChildrenRequest.newBuilder().build());
List<GalaxyObject> children = reply.getChildrenList();
List<Boolean> hasChildren = reply.getChildHasChildrenList();
for (int i = 0; i < children.size(); i++) {
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
List<LazyBrowseNode> roots = galaxy.browse();
for (LazyBrowseNode root : roots) {
if (root.hasChildrenHint()) {
root.expand();
}
for (LazyBrowseNode child : root.getChildren()) {
String kind = child.hasChildrenHint() ? "has children" : "leaf";
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
}
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
@@ -156,8 +244,8 @@ The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
one line per event in text mode or one JSON object per event with `--json`:
```powershell
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
```
## CLI Usage
@@ -165,24 +253,30 @@ gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-k
Run the CLI through Gradle:
```powershell
gradle :mxgateway-cli:run --args="version --json"
gradle :mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
gradle :mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
gradle :mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :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 :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 :mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="ping --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --message hello --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
```
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
`--server-name-override`, `--timeout`, and `--json` on gateway commands. JSON
output redacts API keys.
`--server-name-override`, `--require-certificate-validation`, `--timeout`, and
`--json` on gateway commands. JSON output redacts API keys. TLS is lenient by
default (the certificate is not verified unless you pin a CA with `--ca-file`);
pass `--require-certificate-validation` to verify the server certificate against
the JVM trust store without pinning.
Use TLS options for a secured gateway:
```powershell
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
```
## Build And Test
@@ -202,11 +296,11 @@ in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
Create local library and CLI artifacts from `clients/java`:
```powershell
gradle :mxgateway-client:jar :mxgateway-cli:installDist
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
```
The library jar is under `zb-mom-ww-mxgateway-client/build/libs`. The installed CLI
distribution is under `zb-mom-ww-mxgateway-cli/build/install/mxgateway-cli`.
distribution is under `zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli`.
## Integration Checks
@@ -217,9 +311,40 @@ $env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
```
## Installing from the Gitea Maven repository
The client publishes to the internal Gitea Maven repository at
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
In your consumer project's `build.gradle`:
````groovy
repositories {
maven {
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME')
password = System.getenv('GITEA_TOKEN')
}
}
}
dependencies {
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.1'
}
````
To publish a new version from this repo:
````bash
export GITEA_USERNAME=dohertj2
export GITEA_TOKEN=<your-gitea-token>
gradle :zb-mom-ww-mxgateway-client:publish
````
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
+41 -1
View File
@@ -13,7 +13,7 @@ ext {
subprojects {
group = 'com.zb.mom.ww.mxgateway'
version = '0.1.0'
version = '0.1.1'
pluginManager.withPlugin('java') {
java {
@@ -37,4 +37,44 @@ subprojects {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
}
pluginManager.withPlugin('maven-publish') {
publishing {
publications {
maven(MavenPublication) {
from components.java
pom {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
description = 'MxAccessGateway Java client'
scm {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
}
developers {
developer {
id = 'dohertj2'
name = 'Joseph Doherty'
}
}
licenses {
license {
name = 'Proprietary'
distribution = 'repo'
}
}
}
}
}
repositories {
maven {
name = 'GiteaPackages'
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME') ?: ''
password = System.getenv('GITEA_TOKEN') ?: ''
}
}
}
}
}
}
+4
View File
@@ -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 {
@@ -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();
}
}
@@ -354,6 +354,9 @@ public final class MxAccessGatewayGrpc {
* 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.
* </pre>
*/
default void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
@@ -457,6 +460,9 @@ public final class MxAccessGatewayGrpc {
* 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.
* </pre>
*/
public void queryActiveAlarms(mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest request,
@@ -545,6 +551,9 @@ public final class MxAccessGatewayGrpc {
* 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.
* </pre>
*/
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
@@ -632,6 +641,9 @@ public final class MxAccessGatewayGrpc {
* 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.
* </pre>
*/
public java.util.Iterator<mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot> queryActiveAlarms(
@@ -1995,9 +1995,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
}
/**
* <pre>
* 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.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.QueryActiveAlarmsRequest}
@@ -2344,9 +2345,10 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile {
}
/**
* <pre>
* 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.
* </pre>
*
* Protobuf type {@code mxaccess_gateway.v1.QueryActiveAlarmsRequest}
@@ -6,6 +6,9 @@ dependencies {
implementation project(':zb-mom-ww-mxgateway-client')
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
implementation "info.picocli:picocli:${picocliVersion}"
testImplementation "io.grpc:grpc-inprocess:${grpcVersion}"
testImplementation "io.grpc:grpc-testing:${grpcVersion}"
}
application {
@@ -0,0 +1,216 @@
package com.zb.mom.ww.mxgateway.cli;
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.StreamObserver;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
/**
* Test fixture that stands up an in-process gRPC server hosting scripted fake
* {@code MxAccessGateway} and {@code GalaxyRepository} service implementations,
* so the real Java client types ({@link MxGatewayClient} /
* {@link GalaxyRepositoryClient}) can be driven over a real channel.
*
* <p>The real streaming wrappers ({@code MxEventStream} /
* {@code DeployEventStream}) have package-private constructors and
* {@link GalaxyRepositoryClient} is {@code final}, so the streaming and galaxy
* CLI commands cannot be exercised through the lightweight {@code FakeSession}
* seam. Driving the real client over an in-process channel against scripted
* services is the clean alternative; Tasks 5 and 6 add the CLI assertions on
* top of this fixture.
*
* <p>Scripted payloads are settable via constructor args or setters. Each
* instance uses a unique server name so harnesses do not collide. The
* {@code directExecutor()} wiring keeps all dispatch on the calling thread, so
* no background threads are leaked.
*/
final class InProcessGatewayHarness implements AutoCloseable {
private final String serverName;
private final Server server;
private final ManagedChannel channel;
private final FakeGatewayService fakeGateway;
private final FakeGalaxyService fakeGalaxy;
/** Starts a harness with empty scripted payloads; populate via setters. */
InProcessGatewayHarness() {
this(List.of(), List.of(), List.of());
}
/**
* Starts a harness with the supplied scripted payloads.
*
* @param scriptedEvents events {@code streamEvents} pushes before completing
* @param scriptedObjects objects {@code discoverHierarchy} returns (single page)
* @param scriptedDeployEvents events {@code watchDeployEvents} streams before completing
*/
InProcessGatewayHarness(
List<MxEvent> scriptedEvents,
List<GalaxyObject> scriptedObjects,
List<DeployEvent> scriptedDeployEvents) {
this.serverName = "mxgw-cli-harness-" + UUID.randomUUID();
this.fakeGateway = new FakeGatewayService(scriptedEvents);
this.fakeGalaxy = new FakeGalaxyService(scriptedObjects, scriptedDeployEvents);
try {
this.server = InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(fakeGateway)
.addService(fakeGalaxy)
.build()
.start();
} catch (IOException error) {
throw new IllegalStateException("failed to start in-process gateway harness", error);
}
this.channel = InProcessChannelBuilder.forName(serverName).directExecutor().build();
}
/** Replaces the scripted {@code streamEvents} payload. */
void setScriptedEvents(List<MxEvent> events) {
fakeGateway.scriptedEvents.clear();
fakeGateway.scriptedEvents.addAll(events);
}
/** Replaces the scripted {@code discoverHierarchy} payload. */
void setScriptedObjects(List<GalaxyObject> objects) {
fakeGalaxy.scriptedObjects.clear();
fakeGalaxy.scriptedObjects.addAll(objects);
}
/** Replaces the scripted {@code watchDeployEvents} payload. */
void setScriptedDeployEvents(List<DeployEvent> deployEvents) {
fakeGalaxy.scriptedDeployEvents.clear();
fakeGalaxy.scriptedDeployEvents.addAll(deployEvents);
}
/**
* Returns the in-process channel into the scripted services.
*
* @return the managed channel; lifecycle owned by the harness
*/
ManagedChannel channel() {
return channel;
}
/**
* Builds a real {@link MxGatewayClient} over the in-process channel.
*
* @return a client borrowing the harness channel
*/
MxGatewayClient gatewayClient() {
return new MxGatewayClient(channel, testOptions());
}
/**
* Builds a real {@link GalaxyRepositoryClient} over the in-process channel.
*
* @return a client borrowing the harness channel
*/
GalaxyRepositoryClient galaxyClient() {
return new GalaxyRepositoryClient(channel, testOptions());
}
private static MxGatewayClientOptions testOptions() {
return MxGatewayClientOptions.builder()
.endpoint("in-process")
.apiKey("mxgw_test_secret")
.plaintext(true)
.callTimeout(Duration.ofSeconds(5))
.build();
}
@Override
public void close() {
channel.shutdownNow();
server.shutdownNow();
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build();
}
/** Scripted fake of the {@code MxAccessGateway} service. */
private static final class FakeGatewayService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
private final List<MxEvent> scriptedEvents = new CopyOnWriteArrayList<>();
FakeGatewayService(List<MxEvent> scriptedEvents) {
this.scriptedEvents.addAll(scriptedEvents);
}
@Override
public void streamEvents(
mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest request,
StreamObserver<MxEvent> responseObserver) {
for (MxEvent event : scriptedEvents) {
responseObserver.onNext(event);
}
responseObserver.onCompleted();
}
@Override
public void closeSession(
CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
responseObserver.onNext(CloseSessionReply.newBuilder()
.setSessionId(request.getSessionId())
.setFinalState(SessionState.SESSION_STATE_CLOSED)
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
}
/** Scripted fake of the {@code GalaxyRepository} service. */
private static final class FakeGalaxyService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
private final List<GalaxyObject> scriptedObjects = new CopyOnWriteArrayList<>();
private final List<DeployEvent> scriptedDeployEvents = new CopyOnWriteArrayList<>();
FakeGalaxyService(List<GalaxyObject> scriptedObjects, List<DeployEvent> scriptedDeployEvents) {
this.scriptedObjects.addAll(scriptedObjects);
this.scriptedDeployEvents.addAll(scriptedDeployEvents);
}
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
List<GalaxyObject> snapshot = new ArrayList<>(scriptedObjects);
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
.setTotalObjectCount(snapshot.size())
.addAllObjects(snapshot)
.setNextPageToken("")
.build());
responseObserver.onCompleted();
}
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
for (DeployEvent event : scriptedDeployEvents) {
responseObserver.onNext(event);
}
responseObserver.onCompleted();
}
}
}
@@ -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}"
@@ -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);
}
}
}
@@ -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");
}
}
@@ -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
@@ -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);
}
}
}
}
@@ -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");
}
}
@@ -0,0 +1,23 @@
package com.zb.mom.ww.mxgateway.client;
import io.grpc.stub.StreamObserver;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
/**
* Cancellable handle returned by {@code streamAlarms}.
*
* <p>Wraps a caller-supplied {@link StreamObserver} and exposes a
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
* subscription also implements {@link AutoCloseable} so it can participate in
* try-with-resources blocks.
*
* <p>All lifecycle / cancellation behaviour is inherited from
* {@link MxGatewayStreamSubscription} (Client.Java-036).
*/
public final class MxGatewayAlarmFeedSubscription
extends MxGatewayStreamSubscription<StreamAlarmsRequest, AlarmFeedMessage> {
public MxGatewayAlarmFeedSubscription() {
super("client cancelled alarm feed");
}
}
@@ -18,6 +18,7 @@ import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
@@ -27,6 +28,7 @@ import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
/**
@@ -320,6 +322,27 @@ public final class MxGatewayClient implements AutoCloseable {
return subscription;
}
/**
* Attaches to the gateway's central alarm feed. The stream opens with one
* {@code AlarmFeedMessage} per currently-active alarm (the ConditionRefresh
* snapshot), then a single {@code snapshot_complete}, then a
* {@code transition} for every subsequent raise / acknowledge / clear.
*
* <p>Served by the gateway's always-on alarm monitor no worker session is
* opened so any number of clients may attach.
*
* @param request the {@code StreamAlarmsRequest}, optionally scoped by
* alarm-reference prefix
* @param observer caller-supplied observer that receives feed messages and completion
* @return a cancellable subscription handle
*/
public MxGatewayAlarmFeedSubscription streamAlarms(
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
withStreamDeadline(rawAsyncStub()).streamAlarms(request, subscription.wrap(observer));
return subscription;
}
@Override
public void close() {
if (ownedChannel != null) {
@@ -361,6 +384,15 @@ public final class MxGatewayClient implements AutoCloseable {
} catch (SSLException error) {
throw new MxGatewayException("failed to configure gateway TLS", error);
}
} else if (!options.requireCertificateValidation()) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util
.InsecureTrustManagerFactory.INSTANCE)
.build());
} catch (SSLException error) {
throw new MxGatewayException("failed to configure lenient gateway TLS", error);
}
} else {
builder.useTransportSecurity();
}
@@ -370,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable {
return builder.build();
}
/**
* Package-visible test seam creates a raw {@link ManagedChannel} from the
* given options without attaching auth interceptors. Used by TLS fixture
* tests to verify channel construction behaviour without a full
* {@link MxGatewayClient} wrapper.
*
* @param options the client options
* @return a new {@link ManagedChannel}
*/
static ManagedChannel createChannelForTests(MxGatewayClientOptions options) {
return createChannel(options);
}
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
@@ -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.
*
@@ -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");
}
}
@@ -1,6 +1,7 @@
package com.zb.mom.ww.mxgateway.client;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
@@ -9,6 +10,8 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommand;
@@ -17,6 +20,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
@@ -27,8 +31,16 @@ import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
/**
* Typed handle for a single MXAccess gateway session.
@@ -421,6 +433,142 @@ public final class MxGatewaySession implements AutoCloseable {
return reply.getUnsubscribeBulk().getResultsList();
}
/**
* Bulk {@code Write} sequential MXAccess Write per entry on the worker's STA.
*
* <p>Per-entry failures appear as {@link BulkWriteResult} entries with
* {@code wasSuccessful == false}; this method does not throw for per-entry
* MXAccess failures (it still throws {@link MxGatewayException} on transport
* or protocol-level failures).
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK)
.setWriteBulk(WriteBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWriteBulk().getResultsList();
}
/**
* Bulk {@code Write2} sequential MXAccess Write2 (timestamped) per entry.
*
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, timestamp, user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2_BULK)
.setWrite2Bulk(Write2BulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWrite2Bulk().getResultsList();
}
/**
* Bulk {@code WriteSecured} credential-sensitive values must not be logged
* by callers; mirrors the single-item write-secured redaction contract.
*
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, current+verifier user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED_BULK)
.setWriteSecuredBulk(WriteSecuredBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWriteSecuredBulk().getResultsList();
}
/**
* Bulk {@code WriteSecured2} sequential timestamped + verified write per entry.
*
* <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param entries the per-item (handle, value, timestamp, current+verifier user id) tuples
* @return a per-entry {@link BulkWriteResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code entries} is {@code null}
*/
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
Objects.requireNonNull(entries, "entries");
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2_BULK)
.setWriteSecured2Bulk(WriteSecured2BulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllEntries(entries))
.build());
return reply.getWriteSecured2Bulk().getResultsList();
}
/**
* Bulk {@code Read} snapshot the current value of each requested tag.
*
* <p>MXAccess COM has no synchronous read; the worker returns the cached
* {@code OnDataChange} value for any tag that is already advised
* ({@code wasCached == true}) without modifying the existing subscription,
* and falls back to a full AddItem + Advise + wait + UnAdvise + RemoveItem
* snapshot lifecycle otherwise. The supplied {@code timeout} bounds the
* per-tag wait in the snapshot case; pass {@link Duration#ZERO} (or
* {@code null}) to use the worker default (1000 ms). Per-tag failures
* appear as {@link BulkReadResult} entries with {@code wasSuccessful == false};
* this method does not throw for per-tag MXAccess failures.
*
* @param serverHandle the {@code ServerHandle} owning the items
* @param tagAddresses the tag addresses to read
* @param timeout per-tag snapshot timeout (zero or null = worker default)
* @return a per-tag {@link BulkReadResult} list
* @throws MxGatewayException on transport or protocol failure
* @throws NullPointerException if {@code tagAddresses} is {@code null}
* @throws IllegalArgumentException if {@code timeout} is negative or exceeds {@link Integer#MAX_VALUE} milliseconds
*/
public List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout) {
Objects.requireNonNull(tagAddresses, "tagAddresses");
int timeoutMs = 0;
if (timeout != null) {
if (timeout.isNegative()) {
throw new IllegalArgumentException("timeout must be non-negative");
}
long millis = timeout.toMillis();
if (millis > Integer.MAX_VALUE) {
throw new IllegalArgumentException("timeout exceeds Integer.MAX_VALUE milliseconds");
}
timeoutMs = (int) millis;
}
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_READ_BULK)
.setReadBulk(ReadBulkCommand.newBuilder()
.setServerHandle(serverHandle)
.addAllTagAddresses(tagAddresses)
.setTimeoutMs(timeoutMs))
.build());
return reply.getReadBulk().getResultsList();
}
/**
* Invokes MXAccess {@code Write}.
*
@@ -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();
}
}
@@ -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(
@@ -2,6 +2,7 @@ package com.zb.mom.ww.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -23,8 +24,13 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
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;
@@ -35,8 +41,10 @@ import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.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;
@@ -179,6 +187,185 @@ final class MxGatewayClientSessionTests {
}
}
@Test
void queryActiveAlarmsForwardsRequestAndStreamsSnapshots() throws Exception {
AtomicReference<QueryActiveAlarmsRequest> queryRequest = new AtomicReference<>();
TestGatewayService service = new TestGatewayService() {
@Override
public void queryActiveAlarms(
QueryActiveAlarmsRequest request, StreamObserver<ActiveAlarmSnapshot> responseObserver) {
queryRequest.set(request);
responseObserver.onNext(ActiveAlarmSnapshot.newBuilder()
.setAlarmFullReference("Galaxy!TestArea.TestMachine_001.HiTemp")
.setSourceObjectReference("TestMachine_001")
.setAlarmTypeName("HiAlarm")
.setSeverity(800)
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
.setCategory("Process")
.setDescription("High temperature alarm")
.build());
responseObserver.onNext(ActiveAlarmSnapshot.newBuilder()
.setAlarmFullReference("Galaxy!TestArea.TestMachine_002.LoTemp")
.setSourceObjectReference("TestMachine_002")
.setAlarmTypeName("LoAlarm")
.setSeverity(500)
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE_ACKED)
.setOperatorUser("operator-1")
.setOperatorComment("acknowledged")
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
CountDownLatch completed = new CountDownLatch(1);
java.util.List<ActiveAlarmSnapshot> received = new java.util.ArrayList<>();
AtomicReference<Throwable> errorRef = new AtomicReference<>();
QueryActiveAlarmsRequest request = QueryActiveAlarmsRequest.newBuilder()
.setSessionId("alarm-session")
.setClientCorrelationId("test-corr-1")
.setAlarmFilterPrefix("Galaxy!TestArea")
.build();
MxGatewayActiveAlarmsSubscription subscription = client.queryActiveAlarms(
request,
new StreamObserver<>() {
@Override
public void onNext(ActiveAlarmSnapshot value) {
received.add(value);
}
@Override
public void onError(Throwable t) {
errorRef.set(t);
completed.countDown();
}
@Override
public void onCompleted() {
completed.countDown();
}
});
assertTrue(completed.await(5, TimeUnit.SECONDS));
subscription.close();
assertNotNull(queryRequest.get());
assertEquals("alarm-session", queryRequest.get().getSessionId());
assertEquals("test-corr-1", queryRequest.get().getClientCorrelationId());
assertEquals("Galaxy!TestArea", queryRequest.get().getAlarmFilterPrefix());
assertEquals(2, received.size());
assertEquals("Galaxy!TestArea.TestMachine_001.HiTemp", received.get(0).getAlarmFullReference());
assertEquals(
AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE,
received.get(0).getCurrentState());
assertEquals(800, received.get(0).getSeverity());
assertEquals("Galaxy!TestArea.TestMachine_002.LoTemp", received.get(1).getAlarmFullReference());
assertEquals(
AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE_ACKED,
received.get(1).getCurrentState());
assertEquals("operator-1", received.get(1).getOperatorUser());
assertNull(errorRef.get());
}
}
@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() {
@@ -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();
}
}
}
@@ -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();
}
}
+22
View File
@@ -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
+114 -3
View File
@@ -95,6 +95,13 @@ async with await GatewayClient.connect(
events available for parity tests. `Session` helpers call the method-specific
MXAccess commands and preserve raw replies on typed command exceptions.
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
messages from the gateway's central monitor), and
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
and ack target). Cancel the surrounding task or `aclose()` the iterator to
terminate the stream.
Canceling a Python task cancels the client-side gRPC call or stream wait. It
does not abort an in-flight MXAccess COM call inside the worker process.
@@ -131,6 +138,49 @@ The methods return native Python types (`bool`, `datetime | None`, and a
into the hierarchy without learning the underlying stub class. The
service requires the `metadata:read` scope on the API key.
### Browsing lazily
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
time instead of loading the full hierarchy with `discover_hierarchy`. Pass an
empty request for root objects; subsequent calls set `parent_gobject_id`,
`parent_tag_name`, or `parent_contained_path`. Filter fields match
`DiscoverHierarchy`. Each response pairs `children` with `child_has_children` so
you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```python
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb2
reply = await galaxy.browse_children(galaxy_pb2.BrowseChildrenRequest())
for child, has_children in zip(reply.children, reply.child_has_children):
print(child.tag_name, "expand=" + str(has_children))
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```python
async with await GalaxyRepositoryClient.connect(
endpoint="localhost:5000",
api_key="<gateway-api-key>",
plaintext=True,
) as galaxy:
roots = await galaxy.browse()
for root in roots:
if root.has_children_hint:
await root.expand()
for child in root.children:
kind = "has children" if child.has_children_hint else "leaf"
print(f"{child.object.tag_name} ({kind})")
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
@@ -164,9 +214,33 @@ The method returns an async iterator yielding the generated `DeployEvent`
proto. Breaking out of the loop, calling `aclose()` on the iterator, or
cancelling the surrounding task closes the underlying gRPC stream
cleanly. The streaming RPC requires the same `metadata:read` scope as
the other Galaxy methods. The CLI does not currently expose a
streaming `watch-deploy-events` subcommand — use the library API
directly when subscribing to deploy events from Python.
the other Galaxy methods.
The CLI exposes the Galaxy Repository RPCs through five subcommands that
mirror the other clients:
```bash
mxgw-py galaxy-test-connection --plaintext --json
mxgw-py galaxy-last-deploy --plaintext --json
mxgw-py galaxy-discover --plaintext --json
mxgw-py galaxy-browse --plaintext --json
mxgw-py galaxy-watch --plaintext --json
```
`galaxy-watch` is bounded by `--max-events` (default `1`) and `--timeout`
(seconds) so it always terminates; pass `--last-seen-deploy-time` (an
ISO-8601 timestamp) to suppress the bootstrap event when it matches the
current cached deploy time.
`galaxy-browse` wraps the lazy `LazyBrowseNode` walker. Without `--depth`
it lists only the root objects; `--depth N` eagerly expands `N` further
levels before printing. Text output is a node count followed by an indented
tree (`+`/`-` marks the server's has-children hint); `--json` emits nested
`{..., "hasChildrenHint": bool, "children": [...]}` nodes that match the
`galaxy-discover` object shape. The `BrowseChildrenRequest` filters are
exposed as `--category-id` (repeatable), `--template-chain-contains`
(repeatable), `--tag-name-glob`, `--include-attributes`,
`--alarm-bearing-only`, and `--historized-only`, all AND-combined.
## Authentication And TLS
@@ -180,6 +254,21 @@ The client supports plaintext channels for local development, TLS with system
roots, TLS with a custom `ca_file`, and an optional test server name override.
API keys are redacted from option repr output and CLI error output.
The gateway can auto-generate its own self-signed certificate (it has no PKI).
grpc-python has no per-channel skip-verify, so the lenient TLS default is
**trust-on-first-use**: with no `ca_file` and `require_certificate_validation`
left `False`, the client fetches the gateway's presented certificate once
(unverified) and pins it for the channel, defaulting the SNI/target-name override
to `localhost` (the generated certificate always carries a `localhost` SAN) when
none was supplied. To verify instead, pass `ca_file` to verify against a specific
CA, or set `require_certificate_validation=True` to verify against the system
trust roots. The strict posture is reachable through every documented entry
point: the `require_certificate_validation=True` keyword on
`GatewayClient.connect(...)` / `GalaxyRepositoryClient.connect(...)`, the
`ClientOptions(require_certificate_validation=True)` struct, and the
`--require-certificate-validation` CLI flag. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## CLI
The CLI emits deterministic JSON for automation:
@@ -191,6 +280,8 @@ mxgw-py register --session-id <id> --client-name python-client --json
mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --json
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
mxgw-py stream-events --session-id <id> --max-events 1 --json
mxgw-py stream-alarms --max-messages 1 --json
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
```
@@ -204,6 +295,13 @@ Use TLS options for a secured gateway:
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item Object.Attribute --json
```
To force certificate validation against the system trust store instead of the
lenient trust-on-first-use default, add `--require-certificate-validation`:
```powershell
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --require-certificate-validation --api-key-env MXGATEWAY_API_KEY --item Object.Attribute --json
```
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
@@ -216,6 +314,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)
+29 -2
View File
@@ -1,10 +1,12 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
# setuptools >=77 emits core-metadata 2.4 (PEP 639 License-Expression), which the
# Gitea PyPI feed does not yet accept; cap below that so the dist stays <=2.3.
requires = ["setuptools>=69,<77", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "zb-mom-ww-mxaccess-gateway-client"
version = "0.1.0"
version = "0.1.1"
description = "Async Python client scaffold for MXAccess Gateway."
readme = "README.md"
requires-python = ">=3.12"
@@ -13,12 +15,34 @@ dependencies = [
"grpcio>=1.80,<2",
"protobuf>=6.33,<7",
]
authors = [
{ name = "Joseph Doherty" },
]
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
classifiers = [
"License :: Other/Proprietary License",
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: System :: Distributed Computing",
"Topic :: Software Development :: Libraries :: Python Modules",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
]
[project.urls]
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
Repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
[project.optional-dependencies]
dev = [
"grpcio-tools>=1.80,<2",
"pytest>=9,<10",
"pytest-asyncio>=1.3,<2",
"build>=1.2,<2",
"twine>=5,<6",
]
[project.scripts]
@@ -31,3 +55,6 @@ where = ["src"]
addopts = "-ra"
pythonpath = ["src"]
testpaths = ["tests"]
markers = [
"tls: loopback TLS tests, opt-in via MXGATEWAY_RUN_TLS_TESTS=1",
]
@@ -40,6 +40,7 @@ class GatewayClient:
api_key: str | None = None,
plaintext: bool = False,
ca_file: str | None = None,
require_certificate_validation: bool = False,
server_name_override: str | None = None,
stub: Any | None = None,
) -> "GatewayClient":
@@ -50,13 +51,16 @@ class GatewayClient:
api_key=api_key,
plaintext=plaintext,
ca_file=ca_file,
require_certificate_validation=require_certificate_validation,
server_name_override=server_name_override,
)
if stub is not None:
return cls(options=resolved, stub=stub)
channel = create_channel(resolved)
# create_channel may perform a blocking TLS certificate probe (TOFU
# default); run it off the event loop so connect never freezes the loop.
channel = await asyncio.to_thread(create_channel, resolved)
return cls(
options=resolved,
stub=pb_grpc.MxAccessGatewayStub(channel),
@@ -172,6 +176,28 @@ class GatewayClient:
call = self.raw_stub.QueryActiveAlarms(request, **kwargs)
return _canceling_active_alarms_iterator(call)
def stream_alarms(
self,
request: pb.StreamAlarmsRequest,
*,
metadata: Sequence[tuple[str, str]] | None = None,
) -> AsyncIterator[pb.AlarmFeedMessage]:
"""Attach to the gateway's central alarm feed.
The stream opens with one ``AlarmFeedMessage`` per currently-active
alarm (the ConditionRefresh snapshot), then a single
``snapshot_complete``, then a ``transition`` for every subsequent
raise / acknowledge / clear. Served by the gateway's always-on alarm
monitor no worker session is opened so any number of clients may
attach. Optionally scoped by alarm-reference prefix
(``request.alarm_filter_prefix``).
"""
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
if self.options.stream_timeout is not None:
kwargs["timeout"] = self.options.stream_timeout
call = self.raw_stub.StreamAlarms(request, **kwargs)
return _canceling_alarm_feed_iterator(call)
async def _unary(
self,
operation: str,
@@ -223,3 +249,15 @@ async def _canceling_active_alarms_iterator(call: Any) -> AsyncIterator[pb.Activ
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
async def _canceling_alarm_feed_iterator(call: Any) -> AsyncIterator[pb.AlarmFeedMessage]:
try:
async for message in call:
yield message
except grpc.RpcError as error:
raise map_rpc_error("stream alarms", error) from error
finally:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
@@ -21,9 +21,10 @@ from .auth import merge_metadata
from .errors import MxGatewayError, map_rpc_error
from .generated import galaxy_repository_pb2 as galaxy_pb
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
from .options import ClientOptions, create_channel
from .options import BrowseChildrenOptions, ClientOptions, create_channel
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
_BROWSE_CHILDREN_PAGE_SIZE = 500
class GalaxyRepositoryClient:
@@ -51,6 +52,7 @@ class GalaxyRepositoryClient:
api_key: str | None = None,
plaintext: bool = False,
ca_file: str | None = None,
require_certificate_validation: bool = False,
server_name_override: str | None = None,
stub: Any | None = None,
) -> "GalaxyRepositoryClient":
@@ -61,13 +63,16 @@ class GalaxyRepositoryClient:
api_key=api_key,
plaintext=plaintext,
ca_file=ca_file,
require_certificate_validation=require_certificate_validation,
server_name_override=server_name_override,
)
if stub is not None:
return cls(options=resolved, stub=stub)
channel = create_channel(resolved)
# create_channel may perform a blocking TLS certificate probe (TOFU
# default); run it off the event loop so connect never freezes the loop.
channel = await asyncio.to_thread(create_channel, resolved)
return cls(
options=resolved,
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
@@ -139,6 +144,89 @@ class GalaxyRepositoryClient:
)
seen_page_tokens.add(page_token)
async def browse_children_raw(
self, request: galaxy_pb.BrowseChildrenRequest
) -> galaxy_pb.BrowseChildrenReply:
"""Issue one BrowseChildren RPC and return the raw reply.
Lower-level escape hatch for callers that need direct page-token control
or do not want LazyBrowseNode wrapping. Most callers should use
:py:meth:`browse` and :py:meth:`LazyBrowseNode.expand` instead.
"""
return await self._unary(
"browse children",
self.raw_stub.BrowseChildren,
request,
)
async def browse(
self,
options: BrowseChildrenOptions | None = None,
) -> list["LazyBrowseNode"]:
"""Return the root browse nodes for lazy hierarchy traversal.
Each returned ``LazyBrowseNode`` wraps a Galaxy object whose direct
children can be loaded on demand by ``await node.expand()``.
"""
effective = options or BrowseChildrenOptions()
return [
node
async for node in self._iter_browse_children(
parent_gobject_id=None,
options=effective,
)
]
async def _iter_browse_children(
self,
*,
parent_gobject_id: int | None,
options: BrowseChildrenOptions,
) -> AsyncIterator["LazyBrowseNode"]:
page_token = ""
seen_page_tokens: set[str] = set()
while True:
request = galaxy_pb.BrowseChildrenRequest(
page_size=_BROWSE_CHILDREN_PAGE_SIZE,
page_token=page_token,
alarm_bearing_only=options.alarm_bearing_only,
historized_only=options.historized_only,
)
if parent_gobject_id is not None:
request.parent_gobject_id = parent_gobject_id
if options.category_ids:
request.category_ids.extend(options.category_ids)
if options.template_chain_contains:
request.template_chain_contains.extend(options.template_chain_contains)
if options.tag_name_glob:
request.tag_name_glob = options.tag_name_glob
if options.include_attributes is not None:
request.include_attributes = options.include_attributes
reply = await self._unary(
"browse children",
self.raw_stub.BrowseChildren,
request,
)
for index, obj in enumerate(reply.children):
hint = (
index < len(reply.child_has_children)
and bool(reply.child_has_children[index])
)
yield LazyBrowseNode(self, obj, hint, options)
page_token = reply.next_page_token
if not page_token:
return
if page_token in seen_page_tokens:
raise MxGatewayError(
f"galaxy browse children returned repeated page token {page_token!r}"
)
seen_page_tokens.add(page_token)
def watch_deploy_events(
self,
last_seen_deploy_time: datetime | None = None,
@@ -202,6 +290,67 @@ class GalaxyRepositoryClient:
raise map_rpc_error(operation, error) from error
class LazyBrowseNode:
"""One node in a lazy-loaded Galaxy browse tree.
Calling ``expand`` once fetches direct children (paginating as needed)
and populates ``children``. Subsequent calls are no-ops so callers can
drive UI expand toggles without de-duping.
"""
def __init__(
self,
client: "GalaxyRepositoryClient",
obj: galaxy_pb.GalaxyObject,
has_children_hint: bool,
options: BrowseChildrenOptions,
) -> None:
"""Initialize a node bound to its owning client and filter set."""
self._client = client
self._object = obj
self._has_children_hint = has_children_hint
self._options = options
self._children: list[LazyBrowseNode] = []
self._is_expanded = False
self._expand_lock = asyncio.Lock()
@property
def object(self) -> galaxy_pb.GalaxyObject:
"""Return the underlying ``GalaxyObject`` proto for this node."""
return self._object
@property
def has_children_hint(self) -> bool:
"""Return the server hint about whether this node has children."""
return self._has_children_hint
@property
def children(self) -> list["LazyBrowseNode"]:
"""Return a copy of the loaded child nodes (empty until expanded)."""
return list(self._children)
@property
def is_expanded(self) -> bool:
"""Return whether ``expand`` has already populated ``children``."""
return self._is_expanded
async def expand(self) -> None:
"""Fetch direct children of this node; no-op on subsequent calls."""
if self._is_expanded:
return
async with self._expand_lock:
if self._is_expanded:
return
new_children: list[LazyBrowseNode] = []
async for child in self._client._iter_browse_children(
parent_gobject_id=self._object.gobject_id,
options=self._options,
):
new_children.append(child)
self._children.extend(new_children)
self._is_expanded = True
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
try:
async for event in call:
@@ -26,7 +26,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\"\xdc\x02\n\x15\x42rowseChildrenRequest\x12\x1b\n\x11parent_gobject_id\x18\x01 \x01(\x05H\x00\x12\x19\n\x0fparent_tag_name\x18\x02 \x01(\tH\x00\x12\x1f\n\x15parent_contained_path\x18\x03 \x01(\tH\x00\x12\x11\n\tpage_size\x18\x04 \x01(\x05\x12\x12\n\npage_token\x18\x05 \x01(\t\x12\x14\n\x0c\x63\x61tegory_ids\x18\x06 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x07 \x03(\t\x12\x15\n\rtag_name_glob\x18\x08 \x01(\t\x12\x1f\n\x12include_attributes\x18\t \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\n \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0b \x01(\x08\x42\x08\n\x06parentB\x15\n\x13_include_attributes\"\xb3\x01\n\x13\x42rowseChildrenReply\x12\x34\n\x08\x63hildren\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x19\n\x11total_child_count\x18\x03 \x01(\x05\x12\x1a\n\x12\x63hild_has_children\x18\x04 \x03(\x08\x12\x16\n\x0e\x63\x61\x63he_sequence\x18\x05 \x01(\x04\x32\xb6\x04\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n\x0e\x42rowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -54,6 +54,10 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_GALAXYOBJECT']._serialized_end=1416
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
_globals['_GALAXYREPOSITORY']._serialized_start=1718
_globals['_GALAXYREPOSITORY']._serialized_end=2178
_globals['_BROWSECHILDRENREQUEST']._serialized_start=1718
_globals['_BROWSECHILDRENREQUEST']._serialized_end=2066
_globals['_BROWSECHILDRENREPLY']._serialized_start=2069
_globals['_BROWSECHILDRENREPLY']._serialized_end=2248
_globals['_GALAXYREPOSITORY']._serialized_start=2251
_globals['_GALAXYREPOSITORY']._serialized_end=2817
# @@protoc_insertion_point(module_scope)
@@ -65,6 +65,11 @@ class GalaxyRepositoryStub(object):
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
_registered_method=True)
self.BrowseChildren = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
request_serializer=galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.BrowseChildrenReply.FromString,
_registered_method=True)
class GalaxyRepositoryServicer(object):
@@ -111,6 +116,16 @@ class GalaxyRepositoryServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def BrowseChildren(self, request, context):
"""Returns the direct children of a parent object (or the root objects when
`parent` is unset). Designed for OPC UA-style lazy expand: clients walk
one level at a time instead of paging the full hierarchy. Filters mirror
DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_GalaxyRepositoryServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -134,6 +149,11 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
),
'BrowseChildren': grpc.unary_unary_rpc_method_handler(
servicer.BrowseChildren,
request_deserializer=galaxy__repository__pb2.BrowseChildrenRequest.FromString,
response_serializer=galaxy__repository__pb2.BrowseChildrenReply.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
@@ -263,3 +283,30 @@ class GalaxyRepository(object):
timeout,
metadata,
_registered_method=True)
@staticmethod
def BrowseChildren(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
galaxy__repository__pb2.BrowseChildrenReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@@ -135,6 +135,9 @@ class MxAccessGatewayServicer(object):
reconnect to seed Part 9 client state, or to reconcile alarms that may
have been missed during a transport blip. Streamed so callers can
begin processing without buffering the full set.
`QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
snapshot to alarms whose `alarm_full_reference` starts with the given
prefix; an empty prefix returns the full set.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
@@ -2,12 +2,19 @@
from __future__ import annotations
from dataclasses import dataclass
import ssl
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
import grpc
from .auth import REDACTED, ApiKey
from .errors import MxGatewayTransportError
# Fallback bound for the TOFU certificate probe when no call_timeout is set, so a
# black-holed host fails fast instead of hanging on the OS default connect timeout.
_TOFU_PROBE_TIMEOUT_SECONDS = 10.0
@dataclass(frozen=True)
@@ -18,6 +25,7 @@ class ClientOptions:
api_key: str | ApiKey | None = None
plaintext: bool = False
ca_file: str | None = None
require_certificate_validation: bool = False
server_name_override: str | None = None
call_timeout: float | None = 30.0
stream_timeout: float | None = None
@@ -44,6 +52,7 @@ class ClientOptions:
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
f"ca_file={self.ca_file!r}, "
f"require_certificate_validation={self.require_certificate_validation!r}, "
f"server_name_override={self.server_name_override!r}, "
f"call_timeout={self.call_timeout!r}, "
f"stream_timeout={self.stream_timeout!r}, "
@@ -51,8 +60,60 @@ class ClientOptions:
)
@dataclass(frozen=True)
class BrowseChildrenOptions:
"""Filters and shape options for ``GalaxyRepositoryClient.browse``.
Mirrors the AND-combined filter set on ``BrowseChildrenRequest`` so a
single instance can be re-used across an entire lazy browse session
(the filter set is part of the page-token contract).
"""
category_ids: Sequence[int] = field(default_factory=tuple)
template_chain_contains: Sequence[str] = field(default_factory=tuple)
tag_name_glob: str | None = None
include_attributes: bool | None = None
alarm_bearing_only: bool = False
historized_only: bool = False
def _split_authority(endpoint: str) -> tuple[str, int]:
"""Split a gRPC target (optionally scheme-prefixed) into (host, port).
Handles bracketed IPv6 literals (e.g. ``[::1]:5120`` or bare ``[::1]``),
returning the host without brackets so it is safe to pass to
``ssl.get_server_certificate``.
"""
target = endpoint.split("://", 1)[-1]
if target.startswith("["):
# Bracketed IPv6: "[::1]:5120" or "[::1]"
bracket_end = target.find("]")
host = target[1:bracket_end] # strip surrounding brackets
remainder = target[bracket_end + 1 :] # ":5120" or ""
port_str = remainder.lstrip(":")
return (host, int(port_str) if port_str else 443)
host, sep, port = target.rpartition(":")
if not sep:
# No colon at all (e.g. a bare hostname "mygateway"): the whole target
# is the host; default the port rather than raising on int("mygateway").
return (target or "localhost", 443)
if not port.isdigit():
# A colon with a non-numeric / empty tail (e.g. a trailing ":") is not
# an explicit port — keep the left side as the host and default the
# port so a typo cannot raise an uncaught ValueError on the TOFU path.
return (host or "localhost", 443)
return (host or "localhost", int(port))
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
"""Create a plaintext or TLS `grpc.aio` channel from client options."""
"""Create a plaintext or TLS `grpc.aio` channel from client options.
The TLS default is lenient: grpc-python has no per-channel skip-verify, so
the server's presented certificate is fetched once (unverified) and pinned
as the channel's only trust root (trust-on-first-use). Set
`require_certificate_validation=True` to force system-trust verification, or
pass `ca_file` to verify against a specific CA both bypass the TOFU path.
"""
channel_options: list[tuple[str, str | int]] = [
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
@@ -64,11 +125,34 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel:
if options.plaintext:
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
root_certificates = None
if options.ca_file:
root_certificates = Path(options.ca_file).read_bytes()
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
elif options.require_certificate_validation:
credentials = grpc.ssl_channel_credentials()
else:
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
# server's certificate (unverified) and pin it for this channel (TOFU).
# The probe opens a real blocking TCP+TLS socket, so it MUST be bounded —
# a black-holed / firewall-drop host would otherwise hang on the OS default
# connect timeout (minutes). Bound it by call_timeout (or a short fixed
# fallback) so the dial fails fast as a transport error. The async
# `connect` classmethods run this off the event loop (asyncio.to_thread).
host, port = _split_authority(options.endpoint)
probe_timeout = options.call_timeout if options.call_timeout else _TOFU_PROBE_TIMEOUT_SECONDS
try:
presented = ssl.get_server_certificate((host, port), timeout=probe_timeout)
except OSError as error:
raise MxGatewayTransportError(
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
) from error
credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
# The gateway self-signed cert always carries a "localhost" SAN, so default
# the SNI/target-name override to it when none was supplied, tolerating
# dial-by-IP or hostname mismatch.
if not options.server_name_override:
channel_options.append(("grpc.ssl_target_name_override", "localhost"))
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
return grpc.aio.secure_channel(
options.endpoint,
credentials,
@@ -334,6 +334,138 @@ class Session:
)
return list(reply.unsubscribe_bulk.results)
async def write_bulk(
self,
server_handle: int,
entries: Sequence[pb.WriteBulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `WriteBulk` and return one BulkWriteResult per entry.
Per-entry MXAccess failures appear as results with ``was_successful = False``
and a populated ``error_message`` / ``hresult``; this method does not raise
on per-entry failure, mirroring the existing add/advise bulk surface.
"""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
write_bulk=pb.WriteBulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write_bulk.results)
async def write2_bulk(
self,
server_handle: int,
entries: Sequence[pb.Write2BulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `Write2Bulk` (timestamped) and return per-entry results."""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
write2_bulk=pb.Write2BulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write2_bulk.results)
async def write_secured_bulk(
self,
server_handle: int,
entries: Sequence[pb.WriteSecuredBulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `WriteSecuredBulk` — credential-sensitive values must not be logged."""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
write_secured_bulk=pb.WriteSecuredBulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write_secured_bulk.results)
async def write_secured2_bulk(
self,
server_handle: int,
entries: Sequence[pb.WriteSecured2BulkEntry],
*,
correlation_id: str = "",
) -> list[pb.BulkWriteResult]:
"""Invoke MXAccess `WriteSecured2Bulk` (timestamped + verified)."""
if entries is None:
raise TypeError("entries is required")
_ensure_bulk_size("entries", len(entries))
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
write_secured2_bulk=pb.WriteSecured2BulkCommand(
server_handle=server_handle,
entries=entries,
),
),
correlation_id=correlation_id,
)
return list(reply.write_secured2_bulk.results)
async def read_bulk(
self,
server_handle: int,
tag_addresses: Sequence[str],
*,
timeout_ms: int = 0,
correlation_id: str = "",
) -> list[pb.BulkReadResult]:
"""Invoke `ReadBulk` — snapshot the current value of each requested tag.
MXAccess COM has no synchronous read; the worker returns the cached
``OnDataChange`` value for any tag that is already advised (``was_cached =
True``) without modifying the existing subscription, and falls back to
a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
otherwise. ``timeout_ms`` bounds the per-tag wait in the snapshot case;
pass ``0`` to use the worker default (1000 ms).
"""
if tag_addresses is None:
raise TypeError("tag_addresses is required")
_ensure_bulk_size("tag_addresses", len(tag_addresses))
if timeout_ms < 0:
raise ValueError("timeout_ms must be non-negative")
reply = await self.invoke(
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_READ_BULK,
read_bulk=pb.ReadBulkCommand(
server_handle=server_handle,
tag_addresses=tag_addresses,
timeout_ms=timeout_ms,
),
),
correlation_id=correlation_id,
)
return list(reply.read_bulk.results)
async def write(
self,
server_handle: int,
File diff suppressed because it is too large Load Diff
+234 -23
View File
@@ -1,9 +1,12 @@
"""Tests for auth metadata and connection options."""
import socket
import pytest
from zb_mom_ww_mxgateway.auth import REDACTED, ApiKey, auth_metadata, redact_secret
from zb_mom_ww_mxgateway import options as options_module
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
from zb_mom_ww_mxgateway.options import ClientOptions, create_channel
@@ -72,27 +75,85 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
]
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, object, object]] = []
def test_create_channel_uses_tls_channel_tofu_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Default TLS (no ca_file, no require_certificate_validation) uses TOFU:
fetches the server cert unverified, pins it as root_certificates, and adds
grpc.ssl_target_name_override = "localhost" automatically.
"""
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
get_cert_calls: list[tuple[str, int]] = []
def fake_credentials(*, root_certificates: object) -> str:
assert root_certificates is None
def fake_get_server_certificate(
addr: tuple[str, int], *, timeout: float | None = None
) -> str:
get_cert_calls.append(addr)
return _DUMMY_PEM
cred_calls: list[object] = []
def fake_credentials(*, root_certificates: object = None) -> str:
cred_calls.append(root_certificates)
return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
calls.append((endpoint, credentials, options))
channel_calls.append((endpoint, credentials, options))
return "tls-channel"
monkeypatch.setattr(
options_module.grpc,
"ssl_channel_credentials",
fake_credentials,
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
channel = create_channel(
ClientOptions(endpoint="gateway.example:5001"),
)
assert channel == "tls-channel"
# TOFU: should have fetched the cert from the server (host, port)
assert get_cert_calls == [("gateway.example", 5001)]
# Pinned the fetched PEM bytes as root_certificates
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
# Auto-injected localhost override (no server_name_override supplied)
assert channel_calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
("grpc.ssl_target_name_override", "localhost"),
],
),
]
def test_create_channel_uses_tls_channel_tofu_respects_server_name_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When server_name_override is set, TOFU still runs but does NOT add the
auto-localhost override (the explicit override is already in channel_options).
"""
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
monkeypatch.setattr(
options_module.grpc.aio,
"secure_channel",
fake_secure_channel,
options_module.ssl,
"get_server_certificate",
lambda addr, *, timeout=None: _DUMMY_PEM,
)
cred_calls: list[object] = []
def fake_credentials(*, root_certificates: object = None) -> str:
cred_calls.append(root_certificates)
return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
channel_calls.append((endpoint, credentials, options))
return "tls-channel"
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
channel = create_channel(
ClientOptions(
@@ -102,14 +163,164 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
)
assert channel == "tls-channel"
assert calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
("grpc.ssl_target_name_override", "gateway.test"),
],
),
]
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
assert channel_calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
# Explicit override from ClientOptions — not the auto-localhost one
("grpc.ssl_target_name_override", "gateway.test"),
],
),
]
def test_create_channel_uses_tls_channel_require_cert_validation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""require_certificate_validation=True uses system trust (no TOFU, no root_certificates)."""
get_cert_called = False
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
nonlocal get_cert_called
get_cert_called = True
return "SHOULD_NOT_BE_CALLED"
cred_calls: list[object] = []
def fake_credentials(**kwargs: object) -> str:
cred_calls.append(kwargs)
return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
channel_calls.append((endpoint, credentials, options))
return "tls-channel"
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
channel = create_channel(
ClientOptions(
endpoint="gateway.example:5001",
require_certificate_validation=True,
),
)
assert channel == "tls-channel"
# Must NOT call TOFU prefetch
assert not get_cert_called
# ssl_channel_credentials() called with NO keyword args (system trust)
assert cred_calls == [{}]
assert channel_calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
],
),
]
def test_create_channel_uses_tls_channel_ca_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: pytest.TempPathFactory,
) -> None:
"""ca_file path: reads the PEM file, passes bytes as root_certificates, skips TOFU."""
ca_pem = b"-----BEGIN CERTIFICATE-----\nY2FkYXRh\n-----END CERTIFICATE-----\n"
ca_file = tmp_path / "ca.pem"
ca_file.write_bytes(ca_pem)
get_cert_called = False
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
nonlocal get_cert_called
get_cert_called = True
return "SHOULD_NOT_BE_CALLED"
cred_calls: list[object] = []
def fake_credentials(*, root_certificates: object = None) -> str:
cred_calls.append(root_certificates)
return "creds"
channel_calls: list[tuple[str, object, object]] = []
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
channel_calls.append((endpoint, credentials, options))
return "tls-channel"
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
channel = create_channel(
ClientOptions(
endpoint="gateway.example:5001",
ca_file=str(ca_file),
),
)
assert channel == "tls-channel"
assert not get_cert_called
assert cred_calls == [ca_pem]
assert channel_calls == [
(
"gateway.example:5001",
"creds",
[
("grpc.max_receive_message_length", 16 * 1024 * 1024),
("grpc.max_send_message_length", 16 * 1024 * 1024),
],
),
]
def test_tofu_probe_passes_a_bounded_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""The TOFU cert pre-fetch must be bounded so a black-holed host fails fast."""
captured: dict[str, object] = {}
def fake_get_server_certificate(addr: object, *, timeout: float | None = None) -> str:
captured["timeout"] = timeout
return "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", lambda **_: "creds")
monkeypatch.setattr(
options_module.grpc.aio,
"secure_channel",
lambda endpoint, credentials, *, options: "tls-channel",
)
create_channel(ClientOptions(endpoint="gateway.example:5001", call_timeout=7.5))
# A finite, positive timeout must be supplied (bounded by call_timeout here).
assert isinstance(captured["timeout"], (int, float))
assert 0 < captured["timeout"] <= 7.5
@pytest.mark.parametrize(
"raised",
[socket.timeout("timed out"), TimeoutError("timed out"), OSError("connection refused")],
)
def test_tofu_probe_timeout_raises_transport_error(
monkeypatch: pytest.MonkeyPatch, raised: Exception
) -> None:
"""A timed-out / failed probe surfaces as MxGatewayTransportError, not a raw error."""
def fake_get_server_certificate(addr: object, *, timeout: float | None = None) -> str:
raise raised
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
options = ClientOptions(endpoint="gateway.example:5001")
with pytest.raises(MxGatewayTransportError) as excinfo:
create_channel(options)
assert options.endpoint in str(excinfo.value)
+579
View File
@@ -2,11 +2,78 @@
import json
import pytest
from click.testing import CliRunner
from zb_mom_ww_mxgateway import __version__
from zb_mom_ww_mxgateway_cli import commands as commands_module
from zb_mom_ww_mxgateway_cli.commands import main
_BATCH_EOR = "__MXGW_BATCH_EOR__"
def test_require_certificate_validation_flag_flows_through_connect(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The --require-certificate-validation CLI flag must reach ClientOptions (Client.Python-027)."""
captured: dict[str, object] = {}
async def fake_connect(options, **_kwargs):
captured["options"] = options
# Return a minimal object that supports the async context-manager protocol
# used by every CLI command body (async with await _connect(...) as client).
return _FakeAsyncClient()
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
result = CliRunner().invoke(
main,
[
"open-session",
"--endpoint",
"gateway.example:5001",
"--require-certificate-validation",
"--json",
],
)
assert result.exit_code == 0, result.output
assert captured["options"].require_certificate_validation is True
def test_require_certificate_validation_defaults_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""Without the flag the strict-validation posture stays off (TOFU default)."""
captured: dict[str, object] = {}
async def fake_connect(options, **_kwargs):
captured["options"] = options
return _FakeAsyncClient()
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
result = CliRunner().invoke(
main,
["open-session", "--endpoint", "gateway.example:5001", "--plaintext", "--json"],
)
assert result.exit_code == 0, result.output
assert captured["options"].require_certificate_validation is False
class _FakeAsyncClient:
"""Minimal async-context-manager fake satisfying the open-session command body."""
async def __aenter__(self) -> "_FakeAsyncClient":
return self
async def __aexit__(self, *_exc: object) -> None:
return None
async def open_session_raw(self, *_args, **_kwargs):
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
return pb.OpenSessionReply(session_id="cli-test-session")
def test_version_json_is_deterministic() -> None:
runner = CliRunner()
@@ -48,6 +115,28 @@ def test_write_parser_rejects_unknown_value_type() -> None:
assert "unsupported value type" in result.output
def test_stream_alarms_is_registered() -> None:
runner = CliRunner()
result = runner.invoke(main, ["stream-alarms", "--help"])
assert result.exit_code == 0
assert "--filter-prefix" in result.output
assert "--max-messages" in result.output
def test_acknowledge_alarm_requires_reference() -> None:
runner = CliRunner()
result = runner.invoke(
main,
["acknowledge-alarm", "--api-key", "mxgw_test_secret", "--json"],
)
assert result.exit_code != 0
assert "--reference" in result.output
def test_cli_error_output_redacts_api_key() -> None:
runner = CliRunner()
@@ -66,3 +155,493 @@ def test_cli_error_output_redacts_api_key() -> None:
assert result.exit_code != 0
assert "mxgw_test_secret" not in result.output
def test_batch_runs_version_command_and_writes_eor() -> None:
runner = CliRunner()
result = runner.invoke(main, ["batch"], input="version --json\n")
assert result.exit_code == 0
blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block]
assert len(blocks) == 1
payload = json.loads(blocks[0].strip())
assert payload == {
"client": "mxgw-py",
"package": "mxaccess-gateway-client",
"version": __version__,
}
def test_batch_terminates_on_empty_line() -> None:
runner = CliRunner()
result = runner.invoke(
main,
["batch"],
input="version --json\n\nversion --json\n",
)
assert result.exit_code == 0
# Only the first command runs; the empty line breaks the loop before the second.
assert result.output.count(_BATCH_EOR) == 1
def test_batch_continues_after_error_line() -> None:
runner = CliRunner()
# First line is invalid (unknown subcommand), second is a valid version call.
result = runner.invoke(
main,
["batch"],
input="not-a-real-command\nversion --json\n",
)
assert result.exit_code == 0
assert result.output.count(_BATCH_EOR) == 2
blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block]
assert len(blocks) == 2
# First block: error JSON ({"error": "...", "type": "..."}).
error_payload = json.loads(blocks[0].strip().splitlines()[-1])
assert "error" in error_payload
assert "type" in error_payload
# Second block: successful version JSON.
version_payload = json.loads(blocks[1].strip())
assert version_payload["version"] == __version__
class _FakeGalaxyClient:
"""Minimal async-context-manager fake satisfying the galaxy command bodies."""
def __init__(
self,
*,
ok: bool = True,
objects=None,
last_deploy=None,
events=None,
browse_roots=None,
browse_children_pages=None,
) -> None:
self._ok = ok
self._objects = objects or []
self._last_deploy = last_deploy
self._events = events or []
self._browse_roots = browse_roots or []
# List of BrowseChildrenReply-like objects to serve in order (paged).
self._browse_children_pages = browse_children_pages or []
self._browse_children_calls: list = []
self.browse_options = None
async def __aenter__(self) -> "_FakeGalaxyClient":
return self
async def __aexit__(self, *_exc: object) -> None:
return None
async def test_connection(self) -> bool:
return self._ok
async def discover_hierarchy(self):
return self._objects
async def browse(self, options=None):
self.browse_options = options
return self._browse_roots
async def browse_children_raw(self, request):
"""Return the next queued BrowseChildrenReply page; raises if queue empty."""
self._browse_children_calls.append(request)
if not self._browse_children_pages:
raise AssertionError("browse_children_raw called but no pages queued")
return self._browse_children_pages.pop(0)
async def get_last_deploy_time(self):
# Mirrors galaxy.py: protobuf ToDatetime() yields a timezone-NAIVE UTC datetime.
return self._last_deploy
def watch_deploy_events(self, _last_seen_deploy_time=None):
events = self._events
async def _iter():
for event in events:
yield event
return _iter()
def _patch_galaxy_connect(monkeypatch: pytest.MonkeyPatch, fake: _FakeGalaxyClient) -> None:
async def fake_connect(options, **_kwargs):
return fake
monkeypatch.setattr(commands_module.GalaxyRepositoryClient, "connect", fake_connect)
def test_galaxy_test_connection_emits_ok(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(ok=True))
result = CliRunner().invoke(
main,
["galaxy-test-connection", "--plaintext", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload == {"command": "galaxy-test-connection", "ok": True}
def test_galaxy_discover_serializes_objects(monkeypatch: pytest.MonkeyPatch) -> None:
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
objects = [
galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", contained_name="Area001"),
galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", contained_name="Pump001"),
]
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(objects=objects))
result = CliRunner().invoke(
main,
["galaxy-discover", "--plaintext", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["command"] == "galaxy-discover"
assert len(payload["objects"]) == 2
assert payload["objects"][0]["tagName"] == "Area001"
assert payload["objects"][1]["gobjectId"] == 8
def test_galaxy_last_deploy_emits_utc_iso(monkeypatch: pytest.MonkeyPatch) -> None:
"""The naive-UTC deploy time from the library must be emitted as unambiguous UTC ISO-8601."""
from datetime import datetime
naive_utc = datetime(2025, 6, 15, 12, 0, 0) # noqa: DTZ001 -- mirrors protobuf ToDatetime()
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(last_deploy=naive_utc))
result = CliRunner().invoke(
main,
["galaxy-last-deploy", "--plaintext", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["command"] == "galaxy-last-deploy"
assert payload["present"] is True
assert payload["timeOfLastDeploy"].endswith(("+00:00", "Z"))
def test_galaxy_watch_serializes_deploy_events(monkeypatch: pytest.MonkeyPatch) -> None:
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
events = [galaxy_pb.DeployEvent(sequence=1)]
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(events=events))
result = CliRunner().invoke(
main,
["galaxy-watch", "--plaintext", "--max-events", "1", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["command"] == "galaxy-watch"
assert len(payload["events"]) == 1
class _FakeBrowseNode:
"""Minimal stand-in for LazyBrowseNode covering the CLI render path."""
def __init__(self, obj, *, has_children_hint=False, children=None) -> None:
self._object = obj
self._has_children_hint = has_children_hint
self._children = list(children or [])
self._is_expanded = bool(children)
self.expand_calls = 0
@property
def object(self):
return self._object
@property
def has_children_hint(self) -> bool:
return self._has_children_hint
@property
def children(self):
return list(self._children)
@property
def is_expanded(self) -> bool:
return self._is_expanded
async def expand(self) -> None:
self.expand_calls += 1
self._is_expanded = True
def test_galaxy_browse_serializes_nested_nodes(monkeypatch: pytest.MonkeyPatch) -> None:
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
child = _FakeBrowseNode(
galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", contained_name="Pump001"),
has_children_hint=False,
)
root = _FakeBrowseNode(
galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", contained_name="Area001"),
has_children_hint=True,
children=[child],
)
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root]))
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert "_text" not in payload
assert payload["command"] == "galaxy-browse"
assert len(payload["nodes"]) == 1
node = payload["nodes"][0]
assert node["tagName"] == "Area001"
assert node["hasChildrenHint"] is True
assert len(node["children"]) == 1
assert node["children"][0]["gobjectId"] == 8
assert node["children"][0]["children"] == []
def test_galaxy_browse_renders_indented_text_tree(monkeypatch: pytest.MonkeyPatch) -> None:
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
child = _FakeBrowseNode(
galaxy_pb.GalaxyObject(gobject_id=8, tag_name="Pump001", browse_name="Pump001"),
)
root = _FakeBrowseNode(
galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001", browse_name="Area001"),
has_children_hint=True,
children=[child],
)
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root]))
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext"],
)
assert result.exit_code == 0, result.output
lines = result.output.splitlines()
assert lines[0] == "1"
assert lines[1] == "+ Area001 Area001 (gobject 7)"
assert lines[2] == " - Pump001 Pump001 (gobject 8)"
def test_galaxy_browse_forwards_filter_options(monkeypatch: pytest.MonkeyPatch) -> None:
fake = _FakeGalaxyClient(browse_roots=[])
_patch_galaxy_connect(monkeypatch, fake)
result = CliRunner().invoke(
main,
[
"galaxy-browse",
"--plaintext",
"--category-id",
"10",
"--category-id",
"12",
"--template-chain-contains",
"$Pump",
"--tag-name-glob",
"Area*",
"--include-attributes",
"--alarm-bearing-only",
"--historized-only",
"--json",
],
)
assert result.exit_code == 0, result.output
options = fake.browse_options
assert tuple(options.category_ids) == (10, 12)
assert tuple(options.template_chain_contains) == ("$Pump",)
assert options.tag_name_glob == "Area*"
assert options.include_attributes is True
assert options.alarm_bearing_only is True
assert options.historized_only is True
def test_galaxy_browse_expands_to_depth(monkeypatch: pytest.MonkeyPatch) -> None:
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
root = _FakeBrowseNode(
galaxy_pb.GalaxyObject(gobject_id=7, tag_name="Area001"),
has_children_hint=True,
)
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[root]))
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext", "--depth", "2", "--json"],
)
assert result.exit_code == 0, result.output
assert root.expand_calls == 1
def test_galaxy_commands_are_registered() -> None:
runner = CliRunner()
for command in (
"galaxy-test-connection",
"galaxy-last-deploy",
"galaxy-discover",
"galaxy-watch",
"galaxy-browse",
):
result = runner.invoke(main, [command, "--help"])
assert result.exit_code == 0, result.output
assert "--endpoint" in result.output
@pytest.mark.parametrize("depth_arg", ["99", "-1"])
def test_galaxy_browse_rejects_out_of_range_depth(
monkeypatch: pytest.MonkeyPatch,
depth_arg: str,
) -> None:
"""--depth values outside [0, 50] must be rejected with a non-zero exit."""
_patch_galaxy_connect(monkeypatch, _FakeGalaxyClient(browse_roots=[]))
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext", "--depth", depth_arg, "--json"],
)
assert result.exit_code != 0
assert "--depth must be between 0 and 50" in result.output
# ---------------------------------------------------------------------------
# --parent-gobject-id drill-down tests
# ---------------------------------------------------------------------------
def _fake_browse_children_reply(children_and_hints, *, next_page_token=""):
"""Build a minimal fake BrowseChildrenReply-like object."""
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
reply = galaxy_pb.BrowseChildrenReply()
for obj, hint in children_and_hints:
reply.children.append(obj)
reply.child_has_children.append(hint)
reply.next_page_token = next_page_token
return reply
def test_galaxy_browse_parent_fetches_one_level_json(monkeypatch: pytest.MonkeyPatch) -> None:
"""--parent-gobject-id N calls browse_children_raw and renders one-level JSON."""
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
child_a = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA")
child_b = galaxy_pb.GalaxyObject(gobject_id=11, tag_name="PumpB", browse_name="PumpB")
page = _fake_browse_children_reply([(child_a, True), (child_b, False)])
fake = _FakeGalaxyClient(browse_children_pages=[page])
_patch_galaxy_connect(monkeypatch, fake)
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext", "--parent-gobject-id", "7", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
# One BrowseChildren RPC was issued with the correct parent id.
assert len(fake._browse_children_calls) == 1
call_req = fake._browse_children_calls[0]
assert call_req.parent_gobject_id == 7
# JSON shape mirrors the lazy-browse node shape.
assert payload["command"] == "galaxy-browse"
nodes = payload["nodes"]
assert len(nodes) == 2
assert nodes[0]["tagName"] == "PumpA"
assert nodes[0]["hasChildrenHint"] is True
assert nodes[0]["children"] == []
assert nodes[1]["gobjectId"] == 11
assert nodes[1]["hasChildrenHint"] is False
assert nodes[1]["children"] == []
def test_galaxy_browse_parent_renders_text_tree(monkeypatch: pytest.MonkeyPatch) -> None:
"""--parent-gobject-id N text output: count line then marker lines (no indent)."""
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
child = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA")
page = _fake_browse_children_reply([(child, False)])
fake = _FakeGalaxyClient(browse_children_pages=[page])
_patch_galaxy_connect(monkeypatch, fake)
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext", "--parent-gobject-id", "7"],
)
assert result.exit_code == 0, result.output
lines = result.output.splitlines()
assert lines[0] == "1"
assert lines[1] == "- PumpA PumpA (gobject 10)"
def test_galaxy_browse_parent_pages_correctly(monkeypatch: pytest.MonkeyPatch) -> None:
"""--parent-gobject-id loops on next_page_token until exhausted."""
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
child_a = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA")
child_b = galaxy_pb.GalaxyObject(gobject_id=11, tag_name="PumpB", browse_name="PumpB")
page1 = _fake_browse_children_reply([(child_a, False)], next_page_token="tok1")
page2 = _fake_browse_children_reply([(child_b, True)])
fake = _FakeGalaxyClient(browse_children_pages=[page1, page2])
_patch_galaxy_connect(monkeypatch, fake)
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext", "--parent-gobject-id", "7", "--json"],
)
assert result.exit_code == 0, result.output
assert len(fake._browse_children_calls) == 2
# Second call must carry the page token from the first reply.
assert fake._browse_children_calls[1].page_token == "tok1"
payload = json.loads(result.output)
assert len(payload["nodes"]) == 2
def test_galaxy_browse_parent_warns_when_depth_also_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When both --parent-gobject-id and --depth>0 are supplied a warning is emitted."""
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
child = galaxy_pb.GalaxyObject(gobject_id=10, tag_name="PumpA", browse_name="PumpA")
page = _fake_browse_children_reply([(child, False)])
fake = _FakeGalaxyClient(browse_children_pages=[page])
_patch_galaxy_connect(monkeypatch, fake)
# CliRunner mixes stderr into output in this Click version.
result = CliRunner().invoke(
main,
["galaxy-browse", "--plaintext", "--parent-gobject-id", "7", "--depth", "2", "--json"],
)
assert result.exit_code == 0, result.output
assert "--depth is ignored" in result.output
def test_galaxy_browse_help_shows_parent_gobject_id() -> None:
"""--parent-gobject-id appears in the galaxy-browse --help output."""
result = CliRunner().invoke(main, ["galaxy-browse", "--help"])
assert result.exit_code == 0
assert "--parent-gobject-id" in result.output
@@ -8,9 +8,107 @@ from typing import Any
import pytest
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient, MxAccessError
from zb_mom_ww_mxgateway import client as client_module
from zb_mom_ww_mxgateway import galaxy as galaxy_module
from zb_mom_ww_mxgateway.galaxy import GalaxyRepositoryClient
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
@pytest.mark.asyncio
async def test_gateway_connect_forwards_require_certificate_validation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The connect convenience kwarg must reach ClientOptions (Client.Python-027)."""
captured: dict[str, Any] = {}
def fake_create_channel(options: ClientOptions) -> object:
captured["options"] = options
return object()
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
await GatewayClient.connect(
endpoint="gateway.example:5001",
require_certificate_validation=True,
)
assert captured["options"].require_certificate_validation is True
@pytest.mark.asyncio
async def test_galaxy_connect_forwards_require_certificate_validation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""GalaxyRepositoryClient.connect must thread the flag too (Client.Python-027)."""
captured: dict[str, Any] = {}
def fake_create_channel(options: ClientOptions) -> object:
captured["options"] = options
return object()
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
monkeypatch.setattr(
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
)
await GalaxyRepositoryClient.connect(
endpoint="gateway.example:5001",
require_certificate_validation=True,
)
assert captured["options"].require_certificate_validation is True
@pytest.mark.asyncio
async def test_gateway_connect_runs_create_channel_off_the_event_loop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""connect must run the blocking channel factory off the loop (Client.Python-028)."""
ran_in_thread: dict[str, bool] = {}
def fake_create_channel(options: ClientOptions) -> object:
# If this runs on the event loop thread, get_running_loop() succeeds.
try:
asyncio.get_running_loop()
ran_in_thread["off_loop"] = False
except RuntimeError:
ran_in_thread["off_loop"] = True
return object()
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
await GatewayClient.connect(endpoint="gateway.example:5001")
assert ran_in_thread["off_loop"] is True
@pytest.mark.asyncio
async def test_galaxy_connect_runs_create_channel_off_the_event_loop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""GalaxyRepositoryClient.connect must also run the probe off the loop (Client.Python-028)."""
ran_in_thread: dict[str, bool] = {}
def fake_create_channel(options: ClientOptions) -> object:
try:
asyncio.get_running_loop()
ran_in_thread["off_loop"] = False
except RuntimeError:
ran_in_thread["off_loop"] = True
return object()
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
monkeypatch.setattr(
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
)
await GalaxyRepositoryClient.connect(endpoint="gateway.example:5001")
assert ran_in_thread["off_loop"] is True
@pytest.mark.asyncio
async def test_session_helpers_send_auth_metadata_and_preserve_raw_replies() -> None:
stub = FakeGatewayStub()
+276
View File
@@ -6,12 +6,16 @@ import asyncio
from datetime import datetime, timezone
from typing import Any
import grpc
import pytest
from google.protobuf.timestamp_pb2 import Timestamp
from zb_mom_ww_mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
from zb_mom_ww_mxgateway.errors import MxGatewayError
from zb_mom_ww_mxgateway.galaxy import LazyBrowseNode
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions
def test_galaxy_messages_import() -> None:
@@ -268,15 +272,281 @@ async def test_close_marks_channel_closed_when_no_real_channel() -> None:
await client.close()
def _obj(gid: int, tag: str, is_area: bool = False) -> galaxy_pb.GalaxyObject:
return galaxy_pb.GalaxyObject(
gobject_id=gid, tag_name=tag, browse_name=tag, is_area=is_area,
)
def _build_browse_reply(
children: list[galaxy_pb.GalaxyObject],
child_has_children: list[bool],
cache_sequence: int,
next_page_token: str = "",
) -> galaxy_pb.BrowseChildrenReply:
reply = galaxy_pb.BrowseChildrenReply(
total_child_count=len(children),
cache_sequence=cache_sequence,
next_page_token=next_page_token,
)
reply.children.extend(children)
reply.child_has_children.extend(child_has_children)
return reply
def _fake_aio_rpc_error(code: grpc.StatusCode, details: str) -> grpc.aio.AioRpcError:
return grpc.aio.AioRpcError(
code=code,
initial_metadata=grpc.aio.Metadata(),
trailing_metadata=grpc.aio.Metadata(),
details=details,
)
@pytest.mark.asyncio
async def test_browse_no_parent_returns_roots() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True), _obj(2, "Area_B", is_area=True)],
child_has_children=[True, False],
cache_sequence=7,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
assert len(roots) == 2
assert all(isinstance(node, LazyBrowseNode) for node in roots)
assert roots[0].object.tag_name == "Area_A"
assert roots[0].has_children_hint is True
assert roots[1].has_children_hint is False
assert roots[0].is_expanded is False
request = stub.browse_children.requests[0]
assert request.WhichOneof("parent") is None
assert request.page_size == 500
assert request.page_token == ""
@pytest.mark.asyncio
async def test_browse_expand_populates_children_and_marks_expanded() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True)],
child_has_children=[True],
cache_sequence=1,
),
_build_browse_reply(
children=[_obj(11, "Child_A"), _obj(12, "Child_B")],
child_has_children=[False, False],
cache_sequence=1,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
await roots[0].expand()
assert roots[0].is_expanded is True
assert [n.object.tag_name for n in roots[0].children] == ["Child_A", "Child_B"]
assert len(stub.browse_children.requests) == 2
expand_request = stub.browse_children.requests[1]
assert expand_request.WhichOneof("parent") == "parent_gobject_id"
assert expand_request.parent_gobject_id == 1
@pytest.mark.asyncio
async def test_browse_expand_idempotent_no_second_rpc() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True)],
child_has_children=[True],
cache_sequence=1,
),
_build_browse_reply(
children=[_obj(11, "Child_A")],
child_has_children=[False],
cache_sequence=1,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
await roots[0].expand()
await roots[0].expand()
assert len(stub.browse_children.requests) == 2
assert len(roots[0].children) == 1
@pytest.mark.asyncio
async def test_browse_expand_concurrent_callers_only_fire_one_rpc() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7),
_build_browse_reply([_obj(2, "Mixer_001")], [False], 7),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
# Ten concurrent expand calls on the same node should issue exactly one RPC.
await asyncio.gather(*(roots[0].expand() for _ in range(10)))
assert roots[0].is_expanded
assert len(roots[0].children) == 1
# 1 roots fetch + exactly 1 expand fetch = 2 total
assert len(stub.browse_children.requests) == 2
@pytest.mark.asyncio
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(99, "Stale_Parent", is_area=True)],
child_has_children=[True],
cache_sequence=1,
),
]
stub.browse_children.exceptions = [
None,
_fake_aio_rpc_error(grpc.StatusCode.NOT_FOUND, "parent not found"),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
with pytest.raises(MxGatewayError):
await roots[0].expand()
@pytest.mark.asyncio
async def test_browse_expand_multi_page_gathers_all_pages() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(7, "Area_Big", is_area=True)],
child_has_children=[True],
cache_sequence=2,
),
_build_browse_reply(
children=[_obj(71, "Child_1"), _obj(72, "Child_2")],
child_has_children=[False, False],
cache_sequence=2,
next_page_token="7:abc:2",
),
_build_browse_reply(
children=[_obj(73, "Child_3")],
child_has_children=[False],
cache_sequence=2,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
await roots[0].expand()
assert [n.object.tag_name for n in roots[0].children] == ["Child_1", "Child_2", "Child_3"]
assert len(stub.browse_children.requests) == 3
assert stub.browse_children.requests[2].page_token == "7:abc:2"
assert stub.browse_children.requests[2].parent_gobject_id == 7
@pytest.mark.asyncio
async def test_browse_with_filter_forwards_to_request() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True)],
child_has_children=[False],
cache_sequence=3,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
options = BrowseChildrenOptions(
category_ids=(4, 5),
template_chain_contains=("$DelmiaReceiver",),
tag_name_glob="Area_*",
include_attributes=True,
alarm_bearing_only=True,
historized_only=True,
)
await client.browse(options)
request = stub.browse_children.requests[0]
assert list(request.category_ids) == [4, 5]
assert list(request.template_chain_contains) == ["$DelmiaReceiver"]
assert request.tag_name_glob == "Area_*"
assert request.HasField("include_attributes")
assert request.include_attributes is True
assert request.alarm_bearing_only is True
assert request.historized_only is True
@pytest.mark.asyncio
async def test_browse_children_raw_returns_reply_unwrapped() -> None:
"""browse_children_raw forwards the request to the stub and returns the raw reply."""
stub = FakeGalaxyStub()
expected = _build_browse_reply(
children=[_obj(1, "Plant", is_area=True)],
child_has_children=[True],
cache_sequence=42,
)
stub.browse_children.replies = [expected]
async with await GalaxyRepositoryClient.connect(
endpoint="fake",
plaintext=True,
stub=stub,
) as client:
request = galaxy_pb.BrowseChildrenRequest(
page_size=10,
tag_name_glob="Plant*",
)
reply = await client.browse_children_raw(request)
assert reply.cache_sequence == 42
assert len(reply.children) == 1
assert reply.children[0].tag_name == "Plant"
assert len(stub.browse_children.requests) == 1
assert stub.browse_children.requests[0].tag_name_glob == "Plant*"
class FakeGalaxyStub:
def __init__(self) -> None:
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
self.browse_children = FakeUnary([galaxy_pb.BrowseChildrenReply()])
self.watch_deploy_events = FakeStream([])
self.TestConnection = self.test_connection
self.GetLastDeployTime = self.get_last_deploy_time
self.DiscoverHierarchy = self.discover_hierarchy
self.BrowseChildren = self.browse_children
@property
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
@@ -287,6 +557,8 @@ class FakeUnary:
def __init__(self, replies: list[Any]) -> None:
self.replies = replies
self.requests: list[Any] = []
# None entries mean "no exception on this call"; aligns with the replies queue index-by-index.
self.exceptions: list[BaseException | None] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__(
@@ -298,6 +570,10 @@ class FakeUnary:
) -> Any:
self.requests.append(request)
self.metadata = metadata
if self.exceptions:
exc = self.exceptions.pop(0)
if exc is not None:
raise exc
return self.replies.pop(0)
@@ -0,0 +1,789 @@
"""Regression tests for Client.Python-022..026.
Each test corresponds to a finding from the latest re-review. Tests are
TDD-first they failed against the pre-fix source and pass against the
fixed source.
"""
from __future__ import annotations
import json
import re
import time as _time_module_ref
from pathlib import Path
from typing import Any
import pytest
from click.testing import CliRunner
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
from zb_mom_ww_mxgateway_cli import commands as cli_commands
from zb_mom_ww_mxgateway_cli.commands import _use_plaintext, main
_BATCH_EOR = "__MXGW_BATCH_EOR__"
# ---------------------------------------------------------------------------
# Client.Python-022 — README CLI examples must parse against the implementation.
# ---------------------------------------------------------------------------
def _readme_path() -> Path:
return Path(__file__).resolve().parent.parent / "README.md"
def _extract_mxgw_py_examples() -> list[list[str]]:
"""Return the README's ``mxgw-py ...`` example lines as click arg lists.
Replaces angle-bracket placeholders (``<id>``) with safe stub values and
leaves real flag names untouched. The returned arg lists drop the
``mxgw-py`` prefix.
"""
text = _readme_path().read_text(encoding="utf-8")
args: list[list[str]] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line.startswith("mxgw-py "):
continue
# Strip the leading "mxgw-py " token.
body = line[len("mxgw-py ") :]
# Replace common placeholders so click does not error on the placeholder.
body = body.replace("<id>", "session-1")
# Backtick-quoted hostnames in the TLS example are not represented
# in CLI; safe to leave as-is.
tokens = _split_cli_tokens(body)
# Keep only examples that exercise a real subcommand. Skip TLS
# multi-flag example (we only need the README CLI examples added in
# commits 8738735 — stream-alarms / acknowledge-alarm).
args.append(tokens)
return args
def _split_cli_tokens(body: str) -> list[str]:
"""Split a CLI body into argv tokens, honouring double-quoted strings."""
tokens: list[str] = []
pattern = re.compile(r'"([^"]*)"|(\S+)')
for match in pattern.finditer(body):
quoted, plain = match.group(1), match.group(2)
tokens.append(quoted if quoted is not None else plain)
return tokens
def test_readme_alarm_examples_parse_against_cli() -> None:
"""README `stream-alarms` / `acknowledge-alarm` examples must parse without
triggering Click's ``no such option`` error.
Drives every README ``mxgw-py`` example through Click's ``--help`` style
parser by re-invoking the documented argv with a trailing ``--help`` flag so
only the parser runs (no RPC is attempted). If a documented flag does not
exist on the subcommand, Click prints ``no such option: --<flag>`` and
exits 2 that is the regression we want to catch.
"""
runner = CliRunner()
examples = _extract_mxgw_py_examples()
assert any(
"stream-alarms" in args for args in examples
), "README must include a stream-alarms example."
assert any(
"acknowledge-alarm" in args for args in examples
), "README must include an acknowledge-alarm example."
for argv in examples:
# Strip "--json" (already a real flag) and any value-bearing flag that
# requires a host/file/value, then append --help so we exercise the
# parser only.
# We just append --help — Click parses all options up to --help and
# then prints help; an unknown option still errors out first.
result = runner.invoke(main, [*argv, "--help"])
# Either help text printed (exit 0) or some other parser issue (exit 2);
# we only want to assert NO "no such option" error.
assert "no such option" not in result.output.lower(), (
f"README example failed Click parsing: argv={argv!r}\n"
f"output={result.output!r}"
)
# ---------------------------------------------------------------------------
# Client.Python-023 — REGRESSION of Client.Python-013. _use_plaintext must
# not silently auto-downgrade on localhost / 127.0.0.1.
# ---------------------------------------------------------------------------
def test_use_plaintext_does_not_auto_downgrade_for_localhost_endpoint() -> None:
"""A bare ``localhost:...`` endpoint with no flags must default to TLS."""
assert _use_plaintext({
"endpoint": "localhost:5001",
"plaintext": False,
"use_tls": False,
}) is False
def test_use_plaintext_does_not_auto_downgrade_for_loopback_ipv4_endpoint() -> None:
"""A bare ``127.0.0.1:...`` endpoint with no flags must default to TLS."""
assert _use_plaintext({
"endpoint": "127.0.0.1:5001",
"plaintext": False,
"use_tls": False,
}) is False
def test_use_plaintext_requires_explicit_plaintext_flag() -> None:
"""``--plaintext`` is the only way to opt in."""
assert _use_plaintext({
"endpoint": "localhost:5001",
"plaintext": True,
"use_tls": False,
}) is True
def test_use_plaintext_tls_flag_explicitly_disables_plaintext() -> None:
"""``--tls`` is accepted as an explicit affirmation of the default."""
assert _use_plaintext({
"endpoint": "localhost:5001",
"plaintext": False,
"use_tls": True,
}) is False
def test_use_plaintext_rejects_plaintext_and_tls_combined() -> None:
"""``--plaintext`` and ``--tls`` together must be rejected as ambiguous."""
import click as _click
with pytest.raises(_click.UsageError):
_use_plaintext({
"endpoint": "localhost:5001",
"plaintext": True,
"use_tls": True,
})
def test_cli_localhost_endpoint_with_no_flags_uses_tls_channel(monkeypatch) -> None:
"""End-to-end CLI: against ``localhost:...`` with no flags, the resolved
``ClientOptions.plaintext`` flowing into ``GatewayClient.connect`` must be
``False`` (TLS), so the API key bearer cannot leak over plaintext.
"""
captured: dict[str, Any] = {}
class _FakeStub:
def __init__(self) -> None:
pass
async def OpenSession(self, request: Any, *, metadata: tuple[Any, ...]) -> Any:
captured["metadata"] = metadata
return pb.OpenSessionReply(
session_id="session-1",
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
)
real_connect = GatewayClient.connect
@classmethod
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
captured["options"] = options
return await real_connect(options, stub=_FakeStub())
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
runner = CliRunner()
result = runner.invoke(
main,
[
"open-session",
"--endpoint",
"localhost:5000",
"--api-key",
"mxgw_test_secret",
"--json",
],
)
assert result.exit_code == 0, result.output
assert "options" in captured
assert captured["options"].plaintext is False, (
"localhost endpoint without --plaintext must NOT auto-downgrade to plaintext"
)
# ---------------------------------------------------------------------------
# Client.Python-024 — `batch` must not use CliRunner from production code,
# and a recursive `batch` line must not silently re-enter.
# ---------------------------------------------------------------------------
def test_batch_command_does_not_use_clirunner_in_production() -> None:
"""`commands.py` must not import or instantiate the test-only CliRunner helper.
Docstring references explaining what the module deliberately avoids are
permitted; what is forbidden is an actual ``import`` of ``click.testing``
or an actual ``CliRunner()`` instantiation in executable code.
"""
source = Path(cli_commands.__file__).read_text(encoding="utf-8")
assert "from click.testing" not in source, (
"click.testing is a test-only helper and must not be used by production code"
)
assert "import click.testing" not in source, (
"click.testing is a test-only helper and must not be used by production code"
)
# `CliRunner()` (instantiation) must not appear in production code.
assert "CliRunner(" not in source, (
"CliRunner() must not be instantiated in production code"
)
def test_batch_recursive_batch_line_is_bounded() -> None:
"""A `batch` line nested inside `batch` stdin must not be silently spawned.
The pre-fix implementation re-invoked the test runner with empty stdin,
so `batch` inside `batch` exited cleanly with no error. The fix either
rejects the nested invocation or surfaces it as an error block so the
behaviour is auditable.
"""
runner = CliRunner()
result = runner.invoke(
main,
["batch"],
input="batch\nversion --json\n",
)
# Outer batch must still exit 0 and process both lines.
assert result.exit_code == 0
assert result.output.count(_BATCH_EOR) == 2
blocks = [block for block in result.output.split(_BATCH_EOR + "\n") if block]
# The first block — the recursive `batch` line — must surface an error
# JSON. (Either an explicit rejection, or some non-empty error block —
# NOT a silently empty block.)
first_block = blocks[0].strip()
assert first_block, "recursive batch line must not be silently swallowed"
payload = json.loads(first_block.splitlines()[-1])
assert "error" in payload, (
f"recursive batch line should surface an error: got {payload!r}"
)
# ---------------------------------------------------------------------------
# Client.Python-025 — Behavioural tests for new bulk SDK methods,
# stream_alarms, and the new CLI subcommands.
# ---------------------------------------------------------------------------
class _AlarmFakeStream:
def __init__(self, messages: list[pb.AlarmFeedMessage]) -> None:
self._messages = list(messages)
self.cancelled = False
def __aiter__(self) -> "_AlarmFakeStream":
return self
async def __anext__(self) -> pb.AlarmFeedMessage:
if not self._messages:
raise StopAsyncIteration
return self._messages.pop(0)
def cancel(self) -> None:
self.cancelled = True
class _BulkFakeUnary:
def __init__(self, replies: list[Any]) -> None:
self.replies = replies
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
self.requests.append(request)
self.metadata = metadata
return self.replies.pop(0)
class _BulkFakeStub:
def __init__(self) -> None:
self.open_session = _BulkFakeUnary(
[
pb.OpenSessionReply(
session_id="session-1",
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
),
],
)
self.invoke = _BulkFakeUnary([])
self.OpenSession = self.open_session
self.Invoke = self.invoke
self.stream_alarms_metadata: tuple[tuple[str, str], ...] | None = None
self._alarm_stream = _AlarmFakeStream([])
def set_invoke_replies(self, replies: list[Any]) -> None:
self.invoke.replies = replies
def set_alarm_stream(self, stream: _AlarmFakeStream) -> None:
self._alarm_stream = stream
def StreamAlarms(self, request: Any, *, metadata: tuple[tuple[str, str], ...]) -> Any:
self.stream_alarms_request = request
self.stream_alarms_metadata = metadata
return self._alarm_stream
@pytest.mark.asyncio
async def test_session_read_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_READ_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
read_bulk=pb.BulkReadReply(
results=[
pb.BulkReadResult(
tag_address="Tank01.Level",
was_successful=True,
),
],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
results = await session.read_bulk(12, ["Tank01.Level"], timeout_ms=1500)
assert len(results) == 1
assert results[0].tag_address == "Tank01.Level"
request = stub.invoke.requests[0]
assert request.command.kind == pb.MX_COMMAND_KIND_READ_BULK
assert request.command.read_bulk.server_handle == 12
assert list(request.command.read_bulk.tag_addresses) == ["Tank01.Level"]
assert request.command.read_bulk.timeout_ms == 1500
@pytest.mark.asyncio
async def test_session_write_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.WriteBulkEntry(item_handle=34, user_id=99, value=to_mx_value(123)),
]
results = await session.write_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
assert cmd.write_bulk.server_handle == 12
assert cmd.write_bulk.entries[0].item_handle == 34
assert cmd.write_bulk.entries[0].user_id == 99
@pytest.mark.asyncio
async def test_session_write2_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write2_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.Write2BulkEntry(
item_handle=34,
user_id=99,
value=to_mx_value(123),
timestamp_value=to_mx_value(1.5),
),
]
results = await session.write2_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE2_BULK
assert cmd.write2_bulk.server_handle == 12
assert cmd.write2_bulk.entries[0].item_handle == 34
@pytest.mark.asyncio
async def test_session_write_secured_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_secured_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.WriteSecuredBulkEntry(
item_handle=34,
current_user_id=42,
verifier_user_id=43,
value=to_mx_value("secret"),
),
]
results = await session.write_secured_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED_BULK
assert cmd.write_secured_bulk.server_handle == 12
assert cmd.write_secured_bulk.entries[0].current_user_id == 42
assert cmd.write_secured_bulk.entries[0].verifier_user_id == 43
@pytest.mark.asyncio
async def test_session_write_secured2_bulk_sends_expected_request_shape() -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_secured2_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
session = await client.open_session()
from zb_mom_ww_mxgateway.values import to_mx_value
entries = [
pb.WriteSecured2BulkEntry(
item_handle=34,
current_user_id=42,
verifier_user_id=43,
value=to_mx_value("secret"),
timestamp_value=to_mx_value(1.5),
),
]
results = await session.write_secured2_bulk(12, entries)
assert results[0].was_successful is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK
assert cmd.write_secured2_bulk.entries[0].current_user_id == 42
@pytest.mark.asyncio
async def test_stream_alarms_yields_feed_messages_and_cancels_on_close() -> None:
transitions = [
pb.AlarmFeedMessage(
transition=pb.OnAlarmTransitionEvent(
alarm_full_reference="Tank01.Level.HiHi",
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
),
),
]
stream = _AlarmFakeStream(transitions)
stub = _BulkFakeStub()
stub.set_alarm_stream(stream)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
iterator = client.stream_alarms(pb.StreamAlarmsRequest(alarm_filter_prefix="Tank01."))
first = await anext(iterator)
await iterator.aclose()
assert first.transition.alarm_full_reference == "Tank01.Level.HiHi"
assert stream.cancelled
assert stub.stream_alarms_metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert stub.stream_alarms_request.alarm_filter_prefix == "Tank01."
# ---- CLI happy-path coverage for the new subcommands ----
def _install_fake_connect(monkeypatch, stub: Any) -> dict[str, Any]:
"""Patch `GatewayClient.connect` so the CLI uses the supplied fake stub."""
captured: dict[str, Any] = {}
real_connect = GatewayClient.connect
@classmethod
async def _spy_connect(cls, options: ClientOptions, **kwargs: Any) -> GatewayClient:
captured["options"] = options
return await real_connect(options, stub=stub)
monkeypatch.setattr(GatewayClient, "connect", _spy_connect)
return captured
def test_cli_read_bulk_happy_path(monkeypatch) -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_READ_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
read_bulk=pb.BulkReadReply(
results=[
pb.BulkReadResult(
tag_address="Tank01.Level",
was_successful=True,
),
],
),
),
],
)
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"read-bulk",
"--endpoint",
"localhost:5000",
"--plaintext",
"--session-id",
"session-1",
"--server-handle",
"12",
"--items",
"Tank01.Level",
"--timeout-ms",
"1500",
"--json",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["results"][0]["tagAddress"] == "Tank01.Level"
def test_cli_write_bulk_happy_path(monkeypatch) -> None:
stub = _BulkFakeStub()
stub.set_invoke_replies(
[
pb.MxCommandReply(
session_id="session-1",
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
write_bulk=pb.BulkWriteReply(
results=[pb.BulkWriteResult(was_successful=True)],
),
),
],
)
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"write-bulk",
"--endpoint",
"localhost:5000",
"--plaintext",
"--session-id",
"session-1",
"--server-handle",
"12",
"--item-handles",
"34",
"--values",
"123",
"--type",
"int32",
"--json",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["results"][0]["wasSuccessful"] is True
cmd = stub.invoke.requests[0].command
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
transitions = [
pb.AlarmFeedMessage(
transition=pb.OnAlarmTransitionEvent(
alarm_full_reference="Tank01.Level.HiHi",
transition_kind=pb.ALARM_TRANSITION_KIND_RAISE,
),
),
]
stream = _AlarmFakeStream(transitions)
stub = _BulkFakeStub()
stub.set_alarm_stream(stream)
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"stream-alarms",
"--endpoint",
"localhost:5000",
"--plaintext",
"--max-messages",
"1",
"--timeout",
"5.0",
"--filter-prefix",
"Tank01.",
"--json",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["messages"][0]["transition"]["alarmFullReference"] == "Tank01.Level.HiHi"
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
stub = _BulkFakeStub()
stub.acknowledge_alarm = _BulkFakeUnary(
[
pb.AcknowledgeAlarmReply(
correlation_id="corr-1",
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
status=pb.MxStatusProxy(success=1, category=pb.MX_STATUS_CATEGORY_OK),
),
],
)
stub.AcknowledgeAlarm = stub.acknowledge_alarm
_install_fake_connect(monkeypatch, stub)
runner = CliRunner()
result = runner.invoke(
main,
[
"acknowledge-alarm",
"--endpoint",
"localhost:5000",
"--plaintext",
"--reference",
"Tank01.Level.HiHi",
"--comment",
"investigating",
"--operator",
"alice",
"--json",
],
)
assert result.exit_code == 0, result.output
captured_request = stub.acknowledge_alarm.requests[0]
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice"
# ---------------------------------------------------------------------------
# Client.Python-026 — `import time` at module scope; tighter cleanup excepts.
# ---------------------------------------------------------------------------
def test_commands_module_imports_time_at_module_scope() -> None:
"""`time` must be imported at module scope, not inside `_bench_read_bulk`.
`inspect.getsource(_bench_read_bulk)` must not contain a function-local
``import time`` statement.
"""
import inspect
source = inspect.getsource(cli_commands._bench_read_bulk)
# The function body must NOT contain a function-local `import time` line.
for line in source.splitlines():
stripped = line.strip()
assert stripped != "import time", (
f"_bench_read_bulk must not have function-local `import time`: {line!r}"
)
# And the module-level `time` attribute must be present.
assert hasattr(cli_commands, "time"), (
"`time` must be imported at module scope on commands.py"
)
assert cli_commands.time is _time_module_ref
def test_commands_module_bench_read_bulk_does_not_use_bare_except_pass() -> None:
"""The two `except Exception: pass` cleanup blocks in `_bench_read_bulk`
must be removed in favour of either logging or a narrower exception class.
"""
import inspect
source = inspect.getsource(cli_commands._bench_read_bulk)
# Reject the bare `except Exception:` followed by `pass` pattern in
# `_bench_read_bulk`. We tolerate `except Exception as <name>:` because the
# fix logs the exception.
pattern = re.compile(r"except\s+Exception\s*:\s*\n\s*pass\b")
assert not pattern.search(source), (
"_bench_read_bulk cleanup blocks must log or narrow the except clause"
)
+176
View File
@@ -0,0 +1,176 @@
"""TLS behaviour tests for ``create_channel``.
These spin up a real loopback ``grpc.aio`` server with a freshly generated
self-signed certificate (carrying a ``localhost`` SAN, mirroring the gateway's
auto-generated cert) and assert the lenient TOFU default lets a client connect
without any CA configured.
Marked ``tls`` and skipped unless ``MXGATEWAY_RUN_TLS_TESTS=1`` because loopback
TLS handshakes can be timing-flaky on shared CI runners. This mirrors how the
suite gates anything that depends on real sockets rather than fakes.
"""
from __future__ import annotations
import os
import shutil
import socket
import ssl
import subprocess
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path
import grpc
import pytest
import pytest_asyncio
from zb_mom_ww_mxgateway import ClientOptions
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc
from zb_mom_ww_mxgateway.options import create_channel
pytestmark = pytest.mark.tls
_RUN_TLS_TESTS = os.environ.get("MXGATEWAY_RUN_TLS_TESTS") == "1"
_OPENSSL = shutil.which("openssl")
requires_tls = pytest.mark.skipif(
not _RUN_TLS_TESTS,
reason="set MXGATEWAY_RUN_TLS_TESTS=1 to run loopback TLS tests",
)
requires_openssl = pytest.mark.skipif(
_OPENSSL is None,
reason="openssl CLI is required to generate a self-signed test certificate",
)
def _generate_self_signed_cert(directory: Path) -> tuple[Path, Path]:
"""Generate a self-signed cert/key pair with a ``localhost`` SAN."""
key_path = directory / "server.key"
cert_path = directory / "server.crt"
subprocess.run(
[
str(_OPENSSL),
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
str(key_path),
"-out",
str(cert_path),
"-days",
"1",
"-subj",
"/CN=mxgateway-test",
"-addext",
"subjectAltName=DNS:localhost,IP:127.0.0.1",
],
check=True,
capture_output=True,
)
return cert_path, key_path
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class _StaticGatewayServicer(pb_grpc.MxAccessGatewayServicer):
"""Minimal servicer answering ``OpenSession`` with a fixed session id."""
async def OpenSession( # noqa: N802 - generated gRPC method name
self, request: pb.OpenSessionRequest, context: object
) -> pb.OpenSessionReply:
return pb.OpenSessionReply(session_id="tls-session-1")
@pytest_asyncio.fixture
async def tls_server() -> AsyncIterator[int]:
with tempfile.TemporaryDirectory() as tmp:
cert_path, key_path = _generate_self_signed_cert(Path(tmp))
credentials = grpc.ssl_server_credentials(
[(key_path.read_bytes(), cert_path.read_bytes())]
)
server = grpc.aio.server()
pb_grpc.add_MxAccessGatewayServicer_to_server(_StaticGatewayServicer(), server)
port = _free_port()
server.add_secure_port(f"127.0.0.1:{port}", credentials)
await server.start()
try:
yield port
finally:
await server.stop(grace=None)
@requires_tls
@requires_openssl
@pytest.mark.asyncio
async def test_default_tls_connects_via_tofu(tls_server: int) -> None:
"""Default TLS options (no CA) connect by pinning the presented cert."""
options = ClientOptions(
endpoint=f"127.0.0.1:{tls_server}",
api_key="mxgw_test_secret",
)
channel = create_channel(options)
try:
stub = pb_grpc.MxAccessGatewayStub(channel)
reply = await stub.OpenSession(pb.OpenSessionRequest(), timeout=10)
assert reply.session_id == "tls-session-1"
finally:
await channel.close()
def test_split_authority_parses_host_and_port() -> None:
from zb_mom_ww_mxgateway.options import _split_authority
assert _split_authority("https://10.0.0.5:5120") == ("10.0.0.5", 5120)
assert _split_authority("localhost:5120") == ("localhost", 5120)
assert _split_authority(":5120") == ("localhost", 5120)
def test_split_authority_defaults_port_for_portless_endpoint() -> None:
from zb_mom_ww_mxgateway.options import _split_authority
# A bare hostname (no ":port") must default to 443, not crash on int("mygateway").
assert _split_authority("mygateway") == ("mygateway", 443)
# Scheme-prefixed bare hostname behaves the same.
assert _split_authority("https://mygateway") == ("mygateway", 443)
# A non-numeric tail after a colon is treated as no explicit port.
assert _split_authority("mygateway:") == ("mygateway", 443)
def test_split_authority_strips_ipv6_brackets() -> None:
from zb_mom_ww_mxgateway.options import _split_authority
# Bracketed IPv6 with port — brackets must be removed for ssl.get_server_certificate
assert _split_authority("[::1]:5120") == ("::1", 5120)
# Bare bracketed IPv6 (no port) — default port 443
assert _split_authority("[::1]") == ("::1", 443)
# Scheme-prefixed bracketed IPv6
assert _split_authority("grpc://[::1]:5120") == ("::1", 5120)
def test_tofu_connect_failure_raises_transport_error() -> None:
"""A failed cert pre-fetch surfaces the client's transport error type."""
options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}")
with pytest.raises(MxGatewayTransportError) as excinfo:
create_channel(options)
assert options.endpoint in str(excinfo.value)
def test_require_certificate_validation_uses_system_trust() -> None:
"""``require_certificate_validation`` must not attempt a TOFU pre-fetch."""
# Pointing at a closed port: with system-trust the channel is created lazily
# (no eager pre-fetch), so create_channel must succeed without connecting.
options = ClientOptions(
endpoint=f"127.0.0.1:{_free_port()}",
require_certificate_validation=True,
)
channel = create_channel(options)
assert isinstance(channel, grpc.aio.Channel)
+22
View File
@@ -0,0 +1,22 @@
# MSVC-only: bump the default 1 MB Windows stack to 8 MB. clap-derive builds
# a large Command enum in this CLI (one variant per subcommand, each carrying
# flag args); in debug builds the enum is materialized on the stack without
# optimization and overflows the default Windows main-thread stack before
# even reaching our code.
#
# The /STACK: link-arg goes into the PE header's IMAGE_OPTIONAL_HEADER.
# SizeOfStackReserve at link time and applies to both debug and release
# builds — release artifacts ship with the same 8 MB stack reservation. At
# runtime the optimizer elides the enum from the stack frame, so release
# builds would not overflow without this setting; it is kept on for them so
# both build flavours produce binaries with identical stack metadata.
#
# `/STACK:` is an MSVC-linker (`link.exe` / `lld-link`) directive. The
# `target_env = "msvc"` selector below scopes the rustflag to the MSVC
# toolchain so `x86_64-pc-windows-gnu` (mingw) builds, which route link
# args through the GNU linker and reject `/STACK:`, are unaffected.
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "link-arg=/STACK:8388608"]
[registries.dohertj2-gitea]
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
+69 -2
View File
@@ -207,6 +207,22 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "either"
version = "1.15.0"
@@ -574,7 +590,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "mxgw-cli"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"clap",
"futures-util",
@@ -597,6 +613,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -796,6 +818,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -816,6 +850,38 @@ dependencies = [
"untrusted",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -1056,6 +1122,7 @@ dependencies = [
"percent-encoding",
"pin-project",
"prost",
"rustls-native-certs",
"socket2 0.5.10",
"tokio",
"tokio-rustls",
@@ -1423,7 +1490,7 @@ dependencies = [
[[package]]
name = "zb-mom-ww-mxgateway-client"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"futures-core",
"futures-util",
+17 -5
View File
@@ -1,8 +1,17 @@
[package]
name = "zb-mom-ww-mxgateway-client"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
publish = false
authors = ["Joseph Doherty"]
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
license = "Proprietary"
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
documentation = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
readme = "README.md"
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
categories = ["api-bindings", "asynchronous"]
publish = ["dohertj2-gitea"]
build = "build.rs"
[workspace]
@@ -11,8 +20,11 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.1.0"
publish = false
version = "0.1.1"
authors = ["Joseph Doherty"]
license = "Proprietary"
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
publish = ["dohertj2-gitea"]
[workspace.dependencies]
clap = { version = "4.5.53", features = ["derive"] }
@@ -25,7 +37,7 @@ serde_json = "1.0.145"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time"] }
tokio-stream = { version = "0.1.17", features = ["net"] }
tonic = { version = "0.13.1", features = ["transport", "tls-ring"] }
tonic = { version = "0.13.1", features = ["transport", "tls-ring", "tls-native-roots"] }
tonic-build = "0.13.1"
[dependencies]
+95 -1
View File
@@ -62,6 +62,8 @@ cargo run -p mxgw-cli -- register --session-id <session-id> --client-name mxgw-r
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --json
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 1 --json
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
```
@@ -74,6 +76,27 @@ types.
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
```
### TLS trust (pin-only)
The gateway can auto-generate its own self-signed certificate (it has no PKI).
Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1
exposes no public hook to inject a custom certificate verifier, so TLS over Rust
cannot accept an *arbitrary* self-signed certificate. A TLS connection requires
one of two trust paths:
- `--ca-file` / `ClientOptions::with_ca_file(...)` to pin a CA (export the
gateway's self-signed certificate and pin it). This is the path for a
self-signed gateway.
- `--require-certificate-validation` / `with_require_certificate_validation(true)`
to verify against the operating system's trust roots (`tls-native-roots`). This
only succeeds for a certificate that chains to a root the host already trusts —
i.e. a gateway fronted by a publicly- or enterprise-CA-issued certificate, not a
bare self-signed one.
TLS with neither set fails `connect` with a clear, actionable error rather
than accepting the certificate. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## Library Surface
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
@@ -82,7 +105,10 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
<api-key>` metadata to unary and streaming calls.
`GatewayClient` exposes raw generated calls through `open_session_raw`,
`close_session_raw`, `invoke_raw`, `stream_events`, and `raw_client`. The
`close_session_raw`, `invoke_raw`, `stream_events`, `query_active_alarms`,
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
returns an `AlarmFeedStream` async stream of alarm-feed messages and
shares the gateway's central alarm monitor with every other client. The
session helpers keep MXAccess handles visible:
```rust
@@ -133,6 +159,50 @@ cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:500
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
```
### Browsing lazily
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
time instead of paging the full hierarchy. Pass a default request for root
objects; subsequent calls set `parent_gobject_id`, `parent_tag_name`, or
`parent_contained_path`. Filter fields match `discover_hierarchy`. Each response
pairs `children` with `child_has_children` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```rust
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;
let reply = galaxy.browse_children(BrowseChildrenRequest::default()).await?.into_inner();
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
println!("{} expand={}", child.tag_name, has_children);
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```rust
let mut client = GalaxyClient::connect(
ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new(api_key)),
).await?;
let roots = client.browse(None).await?;
for root in &roots {
if root.has_children_hint() {
root.expand().await?;
}
for child in root.children().await {
let kind = if child.has_children_hint() { "has children" } else { "leaf" };
println!("{} ({kind})", child.object().tag_name);
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
@@ -187,3 +257,27 @@ cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Rust Client Detailed Design](./RustClientDesign.md)
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
## Installing from the Gitea Cargo registry
The crate publishes to the internal Gitea Cargo registry. Register the
registry once in your global `~/.cargo/config.toml`:
```toml
[registries.dohertj2-gitea]
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
```
Authentication: cargo reads credentials from `~/.cargo/credentials.toml`:
```toml
[registries.dohertj2-gitea]
token = "Bearer <your-gitea-token>"
```
Then add the dependency:
```toml
[dependencies]
zb-mom-ww-mxgateway-client = { version = "0.1.1", registry = "dohertj2-gitea" }
```
+230 -27
View File
@@ -11,25 +11,38 @@ generated contract inputs.
## Crate Layout
Recommended layout:
The workspace is rooted at `clients/rust/`. The top-level crate
`zb-mom-ww-mxgateway-client` is declared by `clients/rust/Cargo.toml` itself
(flat layout — its `src/` sits directly under the workspace root, not nested
inside `crates/`). The only `[workspace.members]` entry is the `mxgw-cli`
binary subcrate under `crates/mxgw-cli/`.
```text
clients/rust/
Cargo.toml
build.rs
crates/
zb-mom-ww-mxgateway-client/
src/lib.rs
src/client.rs
src/session.rs
src/options.rs
src/auth.rs
src/value.rs
src/error.rs
src/generated/
mxgw-cli/
src/main.rs
Cargo.toml # workspace root + top-level crate `zb-mom-ww-mxgateway-client`
Cargo.lock
build.rs # tonic-build proto generation
README.md
RustClientDesign.md
src/
lib.rs
client.rs
session.rs
galaxy.rs
options.rs
auth.rs
error.rs
value.rs
version.rs
generated.rs # `pub mod` wrappers around files under `src/generated/`
generated/ # tonic-build output (not hand-edited)
tests/
client_behavior.rs
proto_fixtures.rs
crates/
mxgw-cli/ # sole workspace member — binary `mxgw`
Cargo.toml
src/main.rs
```
Expected dependencies:
@@ -43,7 +56,23 @@ Expected dependencies:
- `clap`
- `serde`
- `serde_json`
- `tracing`
## Windows Build Notes
`clients/rust/.cargo/config.toml` carries an MSVC-scoped rustflag that bumps
the default 1 MB Windows main-thread stack to 8 MB
(`-C link-arg=/STACK:8388608`, under `cfg(all(windows, target_env = "msvc"))`).
The setting is required because clap-derive materialises a large `Command`
enum (one variant per CLI subcommand, each carrying its flag args) on the
main task's stack in debug builds, before any user code runs; the default 1
MB stack overflows during enum construction. The `/STACK:` link-arg writes
into the PE header's `IMAGE_OPTIONAL_HEADER.SizeOfStackReserve` at link
time, so both debug and release artifacts ship with the same 8 MB stack
reservation. Release builds would not overflow without it (the optimizer
elides the enum from the stack frame), but the setting is kept on for
release too so both build flavours produce binaries with identical stack
metadata. The MSVC-only selector keeps `x86_64-pc-windows-gnu` (mingw)
builds unaffected, since the GNU linker rejects `/STACK:`.
## Library API
@@ -81,18 +110,126 @@ impl Session {
pub async fn add_item(&self, server_handle: i32, item: &str) -> Result<i32, Error>;
pub async fn add_item2(&self, server_handle: i32, item: &str, context: &str) -> Result<i32, Error>;
pub async fn advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error>;
pub async fn un_advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error>;
pub async fn remove_item(&self, server_handle: i32, item_handle: i32) -> Result<(), Error>;
pub async fn add_item_bulk(&self, server_handle: i32, tag_addresses: Vec<String>) -> Result<Vec<SubscribeResult>, Error>;
pub async fn advise_item_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
pub async fn remove_item_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
pub async fn un_advise_item_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
pub async fn subscribe_bulk(&self, server_handle: i32, tag_addresses: Vec<String>) -> Result<Vec<SubscribeResult>, Error>;
pub async fn unsubscribe_bulk(&self, server_handle: i32, item_handles: Vec<i32>) -> Result<Vec<SubscribeResult>, Error>;
pub async fn read_bulk<S: AsRef<str>>(&self, server_handle: i32, tag_addresses: &[S], timeout_ms: u32) -> Result<Vec<BulkReadResult>, Error>;
pub async fn write(&self, server_handle: i32, item_handle: i32, value: MxValue, user_id: i32) -> Result<(), Error>;
pub async fn write2(&self, server_handle: i32, item_handle: i32, value: MxValue, timestamp_value: MxValue, user_id: i32) -> Result<(), Error>;
pub async fn write_bulk(&self, server_handle: i32, entries: Vec<WriteBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
pub async fn write2_bulk(&self, server_handle: i32, entries: Vec<Write2BulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
pub async fn write_secured_bulk(&self, server_handle: i32, entries: Vec<WriteSecuredBulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
pub async fn write_secured2_bulk(&self, server_handle: i32, entries: Vec<WriteSecured2BulkEntry>) -> Result<Vec<BulkWriteResult>, Error>;
pub async fn events(&self) -> Result<impl Stream<Item = Result<MxEvent, Error>>, Error>;
pub async fn close(&self) -> Result<(), Error>;
}
```
The per-entry credentials and timestamps (`user_id`, `timestamp_value`,
`current_user_id`, `verifier_user_id`) live on the `WriteBulkEntry` /
`Write2BulkEntry` / `WriteSecuredBulkEntry` / `WriteSecured2BulkEntry`
structs rather than as trailing positional arguments on the bulk-write
helpers, matching the protobuf shapes in `mxaccess_gateway.proto`.
`read_bulk` is generic over `AsRef<str>` so callers can pass `&[String]` or
`&[&str]` without cloning at the call site (the cross-language bench-read-bulk
hot loop relies on this).
The `session::next_correlation_id` helper is `pub` and re-exported at the
crate root (`zb_mom_ww_mxgateway_client::next_correlation_id`); raw-RPC
consumers like the `mxgw` CLI's `Ping`, `CloseSession`, `StreamAlarms`,
`AcknowledgeAlarm`, and `BenchReadBulk` paths call it so every request
carries a unique correlation id that gateway logs can tell apart from
concurrent CLI smokes. The textual format is intentionally not part of the
public contract.
## Alarms
`GatewayClient` exposes the gateway's session-less central alarm surface:
```rust
pub type AlarmFeedStream = Pin<Box<dyn Stream<Item = Result<AlarmFeedMessage, Error>> + Send + 'static>>;
impl GatewayClient {
pub async fn stream_alarms(&self, request: StreamAlarmsRequest) -> Result<AlarmFeedStream, Error>;
pub async fn acknowledge_alarm(&self, request: AcknowledgeAlarmRequest) -> Result<AcknowledgeAlarmReply, Error>;
}
```
`stream_alarms` opens with one `active_alarm` per currently-active alarm
(the ConditionRefresh snapshot), then a single `snapshot_complete`, then a
`transition` for every subsequent raise / acknowledge / clear. A fourth
`provider_status` oneof case (`AlarmProviderStatus`: `mode`, `degraded`,
`reason`, `since`) is emitted once on stream open and again on every
failover/failback so late joiners learn the current alarm-provider mode.
The CLI renders all four cases in both its one-line summary and its
protobuf-JSON output (`alarm_feed_message_summary` /
`alarm_feed_message_to_json`). The feed is served by the gateway's always-on
alarm monitor — no worker session is opened — so any number of clients may
attach. Dropping the stream cancels the gRPC call cooperatively.
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
## Galaxy Repository
`GalaxyClient` is a session-less metadata client (requires the
`metadata:read` API-key scope). Alongside `test_connection`,
`get_last_deploy_time`, `discover_hierarchy`, and `watch_deploy_events`, it
exposes a lazy hierarchy walker built on the `BrowseChildren` RPC:
```rust
impl GalaxyClient {
pub async fn browse(&mut self, options: Option<BrowseChildrenOptions>) -> Result<Vec<LazyBrowseNode>, Error>;
pub async fn browse_children_raw(&mut self, request: BrowseChildrenRequest) -> Result<BrowseChildrenReply, Error>;
}
pub struct BrowseChildrenOptions {
pub category_ids: Vec<i32>,
pub template_chain_contains: Vec<String>,
pub tag_name_glob: Option<String>,
pub include_attributes: Option<bool>,
pub alarm_bearing_only: bool,
pub historized_only: bool,
}
impl LazyBrowseNode {
pub fn object(&self) -> &GalaxyObject;
pub fn has_children_hint(&self) -> bool;
pub async fn children(&self) -> Vec<LazyBrowseNode>;
pub async fn is_expanded(&self) -> bool;
pub async fn expand(&self) -> Result<(), Error>;
}
```
- `browse(options)` returns the root objects as `LazyBrowseNode`s. The
supplied `BrowseChildrenOptions` filter is captured and reused when any
returned node is expanded, so a single filter set scopes the entire walk.
- `BrowseChildrenOptions` mirrors the request-level filters on the wire and
combines them with **AND**: a child appears only when it satisfies every
populated criterion (`category_ids` membership, every
`template_chain_contains` substring, the `tag_name_glob`, plus the
`alarm_bearing_only` / `historized_only` flags). `include_attributes` is a
tri-state (`None` = server default). Empty/`None` fields impose no
restriction. See
[Galaxy Repository — BrowseChildren](../../docs/GalaxyRepository.md#browsechildren)
for the wire-level semantics.
- `LazyBrowseNode` is cheap to clone — clones share state through an internal
`Arc`, so expanding one clone makes the children visible to every clone.
`has_children_hint()` exposes the server's `child_has_children` hint so a UI
can draw an expand affordance without issuing an RPC. `expand()` is
idempotent: the first call issues a paged `BrowseChildren` walk (page size
500) under an async mutex held across the await, sets the `is_expanded`
flag, and caches the children; subsequent calls are no-ops and re-hit
nothing. The internal paged loop guards against a server returning a
repeated `next_page_token` by failing with `Error::InvalidArgument` rather
than looping forever.
- `browse_children_raw` issues a single `BrowseChildren` RPC and returns the
raw reply for callers that want to drive paging themselves.
## Authentication
Use a `tonic` interceptor or request extension layer to add:
@@ -113,6 +250,32 @@ Support:
- custom CA file,
- domain override.
### Trust posture (pin-only)
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). Rust is the **exception** to the lenient-by-default posture the other
clients use: tonic 0.13.1 exposes no public hook to inject a custom certificate
verifier, so the Rust client cannot accept an arbitrary certificate. TLS over the
Rust client is therefore **pin-only** — it requires either:
- `ClientOptions::with_ca_file(...)` to pin a CA (the supported path for the
gateway's self-signed certificate; export the certificate and pin it), or
- `ClientOptions::with_require_certificate_validation(true)` to verify against the
operating system's trust roots. This enables the `tonic` `tls-native-roots`
feature and calls `ClientTlsConfig::with_native_roots()`, so the handshake
validates a certificate that chains to a root the host already trusts. It does
**not** accept a bare self-signed gateway certificate — that still needs
`with_ca_file`.
`build_tls_config` computes the trust posture with the pure `tls_trust_decision`
helper (`None` / `PinnedCa` / `SystemRoots` / `RejectNoCa`) so the posture is
unit-testable without a live handshake. With TLS enabled (`with_plaintext(false)`),
no pinned CA, and certificate validation not required (`RejectNoCa`),
`GatewayClient::connect` rejects the connection with a clear, actionable error
pointing at `with_ca_file` / `require_certificate_validation` rather than building
a config with zero trust anchors. The CLI exposes `--ca-file` and
`--require-certificate-validation`.
## Streaming
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
@@ -127,19 +290,31 @@ Use `thiserror`:
```rust
pub enum Error {
InvalidEndpoint { endpoint: String, detail: String },
InvalidArgument { name: String, detail: String },
Transport(tonic::transport::Error),
Status(tonic::Status),
Authentication(String),
Authorization(String),
Session(SessionError),
Worker(WorkerError),
Command(CommandError),
MxAccess(MxAccessError),
Timeout,
Cancelled,
Authentication { message: String, status: Box<tonic::Status> },
Authorization { message: String, status: Box<tonic::Status> },
Timeout { message: String, status: Box<tonic::Status> },
Cancelled { message: String, status: Box<tonic::Status> },
Unavailable { message: String, status: Box<tonic::Status> },
Status(Box<tonic::Status>),
Command(Box<CommandError>),
ProtocolStatus { operation: &'static str, code: ProtocolStatusCode, message: String },
MalformedReply { detail: String },
}
```
- `Unavailable` classifies `Code::Unavailable` / `Code::ResourceExhausted`
so callers can distinguish transient failures from permanent ones.
- `MalformedReply` surfaces the rare case where the gateway returned a
protocol-level `Ok` envelope but the typed payload arm was missing or did
not match the command kind (e.g. an `AddItemReply` body on a `WriteBulk`
reply). This is distinct from `ProtocolStatus` because the protocol-level
envelope itself succeeded; the corruption is in the payload shape.
- `InvalidEndpoint` is raised before any RPC dispatch when the endpoint URL
or CA file fails to parse / load.
Preserve raw command replies in `CommandError` where applicable.
## Test CLI
@@ -152,11 +327,39 @@ Commands:
```text
mxgw version
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
mxgw ping
mxgw open-session
mxgw close-session --session-id <id>
mxgw register --session-id <id>
mxgw add-item --session-id <id> --server-handle <h> --item <tag>
mxgw advise --session-id <id> --server-handle <h> --item-handle <h>
mxgw subscribe-bulk --session-id <id> --server-handle <h> --items <csv>
mxgw unsubscribe-bulk --session-id <id> --server-handle <h> --item-handles <csv>
mxgw read-bulk --session-id <id> --server-handle <h> --items <csv> [--timeout-ms <ms>]
mxgw write --session-id <id> --server-handle 1 --item-handle 1 --value-type int32 --value 123
mxgw write2 --session-id <id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --timestamp <iso>
mxgw write-bulk --session-id <id> --server-handle <h> --item-handles <csv> --value-type <t> --values <csv>
mxgw write2-bulk ...
mxgw write-secured-bulk ...
mxgw write-secured2-bulk ...
mxgw stream-events --session-id <id> --json
mxgw write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
mxgw batch
mxgw galaxy {test-connection,last-deploy-time,discover-hierarchy,watch}
```
`batch` reads commands from stdin one per line and dispatches each through
the normal subcommand path; the loop terminates only on stdin EOF (blank
lines log an empty-EOR-bracketed result and continue) so accidental empty
lines from the PowerShell e2e harness do not silently end the session.
`bench-read-bulk` opens its own session, subscribes to `--bulk-size` tags so
the worker's value cache populates from OnDataChange events, hammers
`read_bulk` in a tight loop for `--duration-seconds`, and emits the
cross-language JSON shape that `scripts/bench-read-bulk.ps1` collates.
JSON output should use `serde_json`.
## Unit Tests
+1 -1
View File
@@ -2,7 +2,7 @@
name = "mxgw-cli"
version.workspace = true
edition.workspace = true
publish.workspace = true
publish = false
[[bin]]
name = "mxgw"
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More