rename: apply ZB.MOM.WW prefix to all client SDKs + fix pre-existing alarm-RPC breaks

Rename across every client surface using each language's idiomatic convention:

  * .NET   clients/dotnet/MxGateway.Client[.Cli|.Tests]/
             -> clients/dotnet/ZB.MOM.WW.MxGateway.Client[.Cli|.Tests]/
             namespaces -> ZB.MOM.WW.MxGateway.Client[.Cli|.Tests]
             contracts ProjectReference repointed to ZB.MOM.WW.MxGateway.Contracts
             sln migrated to slnx (dotnet sln migrate)
  * Python src/mxgateway -> src/zb_mom_ww_mxgateway
             src/mxgateway_cli -> src/zb_mom_ww_mxgateway_cli
             distribution: mxaccess-gateway-client -> zb-mom-ww-mxaccess-gateway-client
  * Rust   crate: mxgateway-client -> zb-mom-ww-mxgateway-client
             build.rs proto path repointed
  * Java   subprojects: mxgateway-{client,cli} -> zb-mom-ww-mxgateway-{client,cli}
             packages com.dohertylan.mxgateway -> com.zb.mom.ww.mxgateway
             group   com.dohertylan.mxgateway -> com.zb.mom.ww.mxgateway
             rootProject mxaccessgw-java -> zb-mom-ww-mxaccessgw-java
  * Go     generate-proto.ps1 proto path repointed; module path and
             package mxgateway kept (Go convention).
  * proto-inputs.json: generatedOutputs.python updated to new package path.
  * scripts/run-client-e2e-tests.ps1: Java CLI install path + gradle task
             updated to zb-mom-ww-mxgateway-cli.

CLI binary names (mxgw, mxgw-py, mxgw-go, mxgateway-cli) and wire-level
identifiers (MXGATEWAY_* env vars, the mxgw_<id>_<secret> API key
prefix, protobuf package names like mxaccess_gateway.v1, all MXAccess
references) intentionally NOT renamed.

Fix pre-existing alarms-over-gateway breaks unblocked by the rename:

  * mxaccess_gateway.proto: add missing public message QueryActiveAlarmsRequest
    {session_id, client_correlation_id, alarm_filter_prefix} and missing
    rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns
    (stream ActiveAlarmSnapshot). All four typed clients referenced
    these but they were absent from the proto.
  * MxAccessGatewayService.QueryActiveAlarms: implement the new RPC on
    the server, streaming from IGatewayAlarmService.CurrentAlarms with
    optional alarm_filter_prefix filter.
  * clients/dotnet/.../DiscoverHierarchyOptions.cs: add the hand-written
    .NET POCO that wraps DiscoverHierarchyRequest (referenced by
    GalaxyRepositoryClient.DiscoverHierarchyAsync but never authored).
  * Drop retired session_id field references from
    AcknowledgeAlarmRequest/AcknowledgeAlarmReply test fixtures across
    .NET, Rust, Go, and Python clients.
  * Rust integration test: add the missing stream_alarms impl on the
    fake MxAccessGateway server (the trait gained the method, fake
    didn't).
  * Rust CLI test: bump expected gatewayProtocolVersion 2 -> 3.

Regenerated artifacts updated in this commit:
  * src/ZB.MOM.WW.MxGateway.Contracts/Generated/{MxaccessGateway,MxaccessGatewayGrpc}.cs
  * clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2{,_grpc}.py
  * clients/go/internal/generated/*.pb.go
(C# regenerated by Grpc.Tools on contracts build; Python and Go via
their generate-proto.ps1 scripts; Rust regenerates from .proto via
tonic-build at compile time so no checked-in artefact.)

Verification: 472 server tests, 275 worker tests (9 dev-rig skipped),
18 integration tests (live MxAccess + LDAP + Galaxy), 57 .NET client
tests, 32 Rust workspace tests, 39 Python tests, all Go packages, and
gradle build for Java all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 19:09:34 -04:00
parent dc9c0c950c
commit 397d3c5c4f
142 changed files with 38852 additions and 2137 deletions
@@ -0,0 +1,985 @@
package com.zb.mom.ww.mxgateway.cli;
import com.zb.mom.ww.mxgateway.client.DeployEventStream;
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
import com.zb.mom.ww.mxgateway.client.MxEventStream;
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientVersion;
import com.zb.mom.ww.mxgateway.client.MxGatewaySecrets;
import com.zb.mom.ww.mxgateway.client.MxGatewaySession;
import com.zb.mom.ww.mxgateway.client.MxValues;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;
/**
* Picocli entry point for the {@code mxgw-java} test CLI used by the
* cross-language smoke matrix.
*/
@Command(
name = "mxgw-java",
mixinStandardHelpOptions = true,
description = "MXAccess Gateway Java test CLI.")
public final class MxGatewayCli implements Callable<Integer> {
private final MxGatewayCliClientFactory clientFactory;
@Spec
private CommandSpec spec;
/**
* Builds a CLI bound to the default gRPC client factory.
*/
public MxGatewayCli() {
this(new GrpcMxGatewayCliClientFactory());
}
MxGatewayCli(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
/**
* Process entry point.
*
* @param args command-line arguments
*/
public static void main(String[] args) {
int exitCode = commandLine(new GrpcMxGatewayCliClientFactory()).execute(args);
System.exit(exitCode);
}
/**
* Test-friendly entry point that runs the CLI against the supplied
* {@link PrintWriter} pair instead of the system streams.
*
* @param out writer that receives standard output
* @param err writer that receives standard error
* @param args command-line arguments
* @return the picocli exit code
*/
public static int execute(PrintWriter out, PrintWriter err, String... args) {
return execute(new GrpcMxGatewayCliClientFactory(), out, err, args);
}
static int execute(MxGatewayCliClientFactory clientFactory, PrintWriter out, PrintWriter err, String... args) {
CommandLine commandLine = commandLine(clientFactory);
commandLine.setOut(out);
commandLine.setErr(err);
return commandLine.execute(args);
}
@Override
public Integer call() {
spec.commandLine().usage(spec.commandLine().getOut());
return 0;
}
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
commandLine.addSubcommand("version", new VersionCommand());
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
commandLine.addSubcommand("close-session", new CloseSessionCommand(clientFactory));
commandLine.addSubcommand("register", new RegisterCommand(clientFactory));
commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory));
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand());
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand());
commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand());
return commandLine;
}
abstract static class GalaxyCommand implements Callable<Integer> {
@Mixin
CommonOptions common = new CommonOptions();
@Option(names = "--json", description = "Write JSON output.")
boolean json;
GalaxyRepositoryClient connect() {
return GalaxyRepositoryClient.connect(common.resolved().toClientOptions());
}
}
@Command(name = "galaxy-test", description = "Calls GalaxyRepository.TestConnection.")
static final class GalaxyTestConnectionCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
boolean ok = client.testConnection();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-test");
output.put("options", common.redactedJsonMap());
output.put("ok", ok);
out.println(jsonObject(output));
} else {
out.println(ok);
}
}
return 0;
}
}
@Command(name = "galaxy-deploy-time", description = "Calls GalaxyRepository.GetLastDeployTime.")
static final class GalaxyDeployTimeCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
Optional<Instant> result = client.getLastDeployTime();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-deploy-time");
output.put("options", common.redactedJsonMap());
output.put("present", result.isPresent());
output.put("timeOfLastDeploy", result.map(Instant::toString).orElse(""));
out.println(jsonObject(output));
} else if (result.isPresent()) {
out.println(result.get());
} else {
out.println("(none)");
}
}
return 0;
}
}
@Command(name = "galaxy-discover", description = "Calls GalaxyRepository.DiscoverHierarchy.")
static final class GalaxyDiscoverCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
List<GalaxyObject> objects = client.discoverHierarchy();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-discover");
output.put("options", common.redactedJsonMap());
output.put("objects", objects.stream().map(MxGatewayCli::galaxyObjectMap).toList());
out.println(jsonObject(output));
} else {
out.printf("count=%d%n", objects.size());
for (GalaxyObject obj : objects) {
out.printf(" %s [%s] attrs=%d%n",
obj.getTagName(), obj.getBrowseName(), obj.getAttributesCount());
}
}
}
return 0;
}
}
@Command(
name = "galaxy-watch",
description = "Streams GalaxyRepository.WatchDeployEvents until cancelled.")
static final class GalaxyWatchCommand extends GalaxyCommand {
@Option(
names = "--last-seen-deploy-time",
description =
"Optional ISO-8601 instant. When supplied, the bootstrap event is suppressed if the cached"
+ " deploy time matches.")
String lastSeenDeployTime;
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print before exiting.")
int limit;
@Override
public Integer call() {
Instant after = parseInstant(lastSeenDeployTime);
try (GalaxyRepositoryClient client = connect();
DeployEventStream events = client.watchDeployEvents(after)) {
PrintWriter out = common.spec.commandLine().getOut();
Thread shutdownHook = new Thread(events::close, "galaxy-watch-shutdown");
Runtime.getRuntime().addShutdownHook(shutdownHook);
try {
int count = 0;
while (events.hasNext()) {
DeployEvent event = events.next();
if (json) {
out.println(protoJson(event));
} else {
out.printf(
"seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
event.getSequence(),
formatTimestamp(event.getObservedAt()),
event.getTimeOfLastDeployPresent()
? formatTimestamp(event.getTimeOfLastDeploy())
: "(none)",
event.getObjectCount(),
event.getAttributeCount());
}
out.flush();
count++;
if (limit > 0 && count >= limit) {
events.close();
break;
}
}
} finally {
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ignored) {
// JVM is already shutting down.
}
}
}
return 0;
}
private static Instant parseInstant(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Instant.parse(value);
}
private static String formatTimestamp(com.google.protobuf.Timestamp ts) {
return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()).toString();
}
}
private static Map<String, Object> galaxyObjectMap(GalaxyObject obj) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("gobjectId", obj.getGobjectId());
values.put("tagName", obj.getTagName());
values.put("containedName", obj.getContainedName());
values.put("browseName", obj.getBrowseName());
values.put("parentGobjectId", obj.getParentGobjectId());
values.put("isArea", obj.getIsArea());
values.put("categoryId", obj.getCategoryId());
values.put("hostedByGobjectId", obj.getHostedByGobjectId());
values.put("templateChain", new ArrayList<>(obj.getTemplateChainList()));
List<Map<String, Object>> attrs = new ArrayList<>();
for (GalaxyAttribute attr : obj.getAttributesList()) {
Map<String, Object> attrMap = new LinkedHashMap<>();
attrMap.put("attributeName", attr.getAttributeName());
attrMap.put("fullTagReference", attr.getFullTagReference());
attrMap.put("mxDataType", attr.getMxDataType());
attrMap.put("dataTypeName", attr.getDataTypeName());
attrMap.put("isArray", attr.getIsArray());
attrMap.put("arrayDimension", attr.getArrayDimension());
attrMap.put("arrayDimensionPresent", attr.getArrayDimensionPresent());
attrMap.put("mxAttributeCategory", attr.getMxAttributeCategory());
attrMap.put("securityClassification", attr.getSecurityClassification());
attrMap.put("isHistorized", attr.getIsHistorized());
attrMap.put("isAlarm", attr.getIsAlarm());
attrs.add(attrMap);
}
values.put("attributes", attrs);
return values;
}
/**
* Picocli subcommand that prints the client and protocol version numbers.
*/
@Command(name = "version", description = "Prints the Java client version.")
public static final class VersionCommand implements Callable<Integer> {
@Spec
private CommandSpec spec;
@Option(names = "--json", description = "Write JSON output.")
private boolean json;
@Override
public Integer call() {
Map<String, Object> values = new LinkedHashMap<>();
values.put("clientVersion", MxGatewayClientVersion.clientVersion());
values.put("gatewayProtocolVersion", MxGatewayClientVersion.gatewayProtocolVersion());
values.put("workerProtocolVersion", MxGatewayClientVersion.workerProtocolVersion());
if (json) {
spec.commandLine().getOut().println(jsonObject(values));
return 0;
}
spec.commandLine()
.getOut()
.printf(
"mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
MxGatewayClientVersion.clientVersion(),
MxGatewayClientVersion.gatewayProtocolVersion(),
MxGatewayClientVersion.workerProtocolVersion());
return 0;
}
}
abstract static class GatewayCommand implements Callable<Integer> {
final MxGatewayCliClientFactory clientFactory;
@Mixin
CommonOptions common = new CommonOptions();
@Option(names = "--json", description = "Write JSON output.")
boolean json;
GatewayCommand(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
}
@Command(name = "open-session", description = "Opens a gateway session.")
static final class OpenSessionCommand extends GatewayCommand {
@Option(names = "--client-session-name", description = "Client session name.")
String clientSessionName = "";
@Option(names = "--backend", description = "Requested gateway backend.")
String backend = "";
OpenSessionCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var reply = client.openSession(OpenSessionRequest.newBuilder()
.setClientSessionName(clientSessionName)
.setRequestedBackend(backend)
.build());
writeOutput("open-session", common, json, reply, () -> reply.getSessionId());
}
return 0;
}
}
@Command(name = "close-session", description = "Closes a gateway session.")
static final class CloseSessionCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
CloseSessionCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var reply = client.closeSession(CloseSessionRequest.newBuilder()
.setSessionId(sessionId)
.build());
writeOutput("close-session", common, json, reply, () -> reply.getFinalState().name());
}
return 0;
}
}
@Command(name = "register", description = "Invokes MXAccess Register.")
static final class RegisterCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--client-name", required = true, description = "MXAccess client name.")
String clientName;
RegisterCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).registerRaw(clientName);
writeOutput("register", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "add-item", description = "Invokes MXAccess AddItem.")
static final class AddItemCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item", required = true, description = "Item definition.")
String item;
AddItemCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).addItemRaw(serverHandle, item);
writeOutput("add-item", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "advise", description = "Invokes MXAccess Advise.")
static final class AdviseCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
AdviseCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply = client.session(sessionId).adviseRaw(serverHandle, itemHandle);
writeOutput("advise", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
static final class SubscribeBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--items", required = true, description = "Comma-separated item definitions.")
String items;
SubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<SubscribeResult> results =
client.session(sessionId).subscribeBulk(serverHandle, parseStringList(items));
writeBulkOutput("subscribe-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "unsubscribe-bulk", description = "Invokes MXAccess UnsubscribeBulk.")
static final class UnsubscribeBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
String itemHandles;
UnsubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
List<SubscribeResult> results =
client.session(sessionId).unsubscribeBulk(serverHandle, parseIntList(itemHandles));
writeBulkOutput("unsubscribe-bulk", common, json, results);
}
return 0;
}
}
@Command(name = "write", description = "Invokes MXAccess Write.")
static final class WriteCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
@Option(names = "--type", defaultValue = "string", description = "Value type.")
String type;
@Option(names = "--value", required = true, description = "Value text.")
String value;
@Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
int userId;
WriteCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
MxCommandReply reply =
client.session(sessionId).writeRaw(serverHandle, itemHandle, parseValue(type, value), userId);
writeOutput("write", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(name = "stream-events", description = "Streams gateway events.")
static final class StreamEventsCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--after-worker-sequence", defaultValue = "0", description = "Starting worker sequence.")
long afterWorkerSequence;
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print.")
int limit;
StreamEventsCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved());
MxEventStream events = client.session(sessionId).streamEventsAfter(afterWorkerSequence)) {
int count = 0;
while (events.hasNext()) {
MxEvent event = events.next();
if (json) {
client.out().println(protoJson(event));
} else {
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
}
count++;
if (limit > 0 && count >= limit) {
events.close();
break;
}
}
}
return 0;
}
}
@Command(name = "smoke", description = "Runs a bounded open/register/add/advise flow.")
static final class SmokeCommand extends GatewayCommand {
@Option(names = "--client-name", defaultValue = "mxgw-java-smoke", description = "MXAccess client name.")
String clientName;
@Option(names = "--item", required = true, description = "Item definition.")
String item;
SmokeCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
var session = client.openSession(OpenSessionRequest.newBuilder()
.setClientSessionName(clientName)
.build());
try {
MxGatewayCliSession cliSession = client.session(session.getSessionId());
int serverHandle = cliSession.register(clientName);
int itemHandle = cliSession.addItem(serverHandle, item);
cliSession.advise(serverHandle, itemHandle);
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "smoke");
output.put("options", common.redactedJsonMap());
output.put("sessionId", session.getSessionId());
output.put("serverHandle", serverHandle);
output.put("itemHandle", itemHandle);
client.out().println(jsonObject(output));
} else {
client.out().printf(
"session=%s server=%d item=%d%n", session.getSessionId(), serverHandle, itemHandle);
}
} finally {
client.closeSession(CloseSessionRequest.newBuilder()
.setSessionId(session.getSessionId())
.build());
}
}
return 0;
}
}
static final class CommonOptions {
@Spec
CommandSpec spec;
@Option(names = "--endpoint", defaultValue = "localhost:5000", description = "Gateway endpoint.")
String endpoint;
@Option(names = "--api-key", description = "Gateway API key.")
String apiKey = "";
@Option(names = "--api-key-env", defaultValue = "MXGATEWAY_API_KEY", description = "API key environment variable.")
String apiKeyEnv;
@Option(names = "--plaintext", description = "Use plaintext transport.")
boolean plaintext;
@Option(names = "--ca-file", description = "CA certificate file.")
Path caFile;
@Option(names = "--server-name-override", description = "TLS server name override.")
String serverNameOverride = "";
@Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
String timeout;
private String resolvedApiKey = "";
private Duration resolvedTimeout = Duration.ofSeconds(30);
CommonOptions resolved() {
resolvedApiKey = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
if (resolvedApiKey == null) {
resolvedApiKey = "";
}
resolvedTimeout = parseDuration(timeout);
return this;
}
MxGatewayClientOptions toClientOptions() {
return MxGatewayClientOptions.builder()
.endpoint(endpoint)
.apiKey(resolvedApiKey)
.plaintext(plaintext)
.caCertificatePath(caFile)
.serverNameOverride(serverNameOverride)
.callTimeout(resolvedTimeout)
.build();
}
Map<String, Object> redactedJsonMap() {
Map<String, Object> values = new LinkedHashMap<>();
values.put("endpoint", endpoint);
values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey));
values.put("apiKeyEnv", apiKeyEnv);
values.put("plaintext", plaintext);
values.put("caFile", caFile == null ? "" : caFile.toString());
values.put("serverNameOverride", serverNameOverride);
values.put("timeout", timeout);
return values;
}
}
interface MxGatewayCliClientFactory {
MxGatewayCliClient connect(CommonOptions options);
}
interface MxGatewayCliClient extends AutoCloseable {
PrintWriter out();
mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request);
mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request);
MxGatewayCliSession session(String sessionId);
@Override
void close();
}
interface MxGatewayCliSession {
int register(String clientName);
MxCommandReply registerRaw(String clientName);
int addItem(int serverHandle, String itemDefinition);
MxCommandReply addItemRaw(int serverHandle, String itemDefinition);
void advise(int serverHandle, int itemHandle);
MxCommandReply adviseRaw(int serverHandle, int itemHandle);
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
MxEventStream streamEventsAfter(long afterWorkerSequence);
}
static final class GrpcMxGatewayCliClientFactory implements MxGatewayCliClientFactory {
@Override
public MxGatewayCliClient connect(CommonOptions options) {
return new GrpcMxGatewayCliClient(MxGatewayClient.connect(options.toClientOptions()), options.spec.commandLine().getOut());
}
}
static final class GrpcMxGatewayCliClient implements MxGatewayCliClient {
private final MxGatewayClient client;
private final PrintWriter out;
GrpcMxGatewayCliClient(MxGatewayClient client, PrintWriter out) {
this.client = client;
this.out = out;
}
@Override
public PrintWriter out() {
return out;
}
@Override
public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request) {
return client.openSessionRaw(request);
}
@Override
public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request) {
return client.closeSessionRaw(request);
}
@Override
public MxGatewayCliSession session(String sessionId) {
return new GrpcMxGatewayCliSession(MxGatewaySession.forSessionId(client, sessionId));
}
@Override
public void close() {
client.close();
}
}
record GrpcMxGatewayCliSession(MxGatewaySession session) implements MxGatewayCliSession {
@Override
public int register(String clientName) {
return session.register(clientName);
}
@Override
public MxCommandReply registerRaw(String clientName) {
return session.registerRaw(clientName);
}
@Override
public int addItem(int serverHandle, String itemDefinition) {
return session.addItem(serverHandle, itemDefinition);
}
@Override
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
return session.addItemRaw(serverHandle, itemDefinition);
}
@Override
public void advise(int serverHandle, int itemHandle) {
session.advise(serverHandle, itemHandle);
}
@Override
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
return session.adviseRaw(serverHandle, itemHandle);
}
@Override
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
return session.writeRaw(serverHandle, itemHandle, value, userId);
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
return session.subscribeBulk(serverHandle, items);
}
@Override
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
return session.unsubscribeBulk(serverHandle, itemHandles);
}
@Override
public MxEventStream streamEventsAfter(long afterWorkerSequence) {
return session.streamEventsAfter(afterWorkerSequence);
}
}
interface TextSupplier {
String get();
}
private static void writeOutput(
String command, CommonOptions common, boolean json, Message reply, TextSupplier textSupplier) {
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", command);
output.put("options", common.redactedJsonMap());
output.put("reply", new RawJson(protoJson(reply)));
out.println(jsonObject(output));
return;
}
out.println(textSupplier.get());
}
private static void writeBulkOutput(
String command, CommonOptions common, boolean json, List<SubscribeResult> results) {
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", command);
output.put("options", common.redactedJsonMap());
output.put("results", results.stream().map(MxGatewayCli::subscribeResultMap).toList());
out.println(jsonObject(output));
return;
}
out.println(results.size());
}
private static Map<String, Object> subscribeResultMap(SubscribeResult result) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("serverHandle", result.getServerHandle());
values.put("tagAddress", result.getTagAddress());
values.put("itemHandle", result.getItemHandle());
values.put("wasSuccessful", result.getWasSuccessful());
values.put("errorMessage", result.getErrorMessage());
return values;
}
private static MxValue parseValue(String type, String text) {
return switch (type) {
case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
case "int32" -> MxValues.int32Value(Integer.parseInt(text));
case "int64" -> MxValues.int64Value(Long.parseLong(text));
case "float" -> MxValues.floatValue(Float.parseFloat(text));
case "double" -> MxValues.doubleValue(Double.parseDouble(text));
case "string" -> MxValues.stringValue(text);
default -> throw new IllegalArgumentException("unsupported value type " + type);
};
}
private static List<String> parseStringList(String value) {
return Arrays.stream(value.split(","))
.map(String::trim)
.filter(item -> !item.isBlank())
.toList();
}
private static List<Integer> parseIntList(String value) {
return parseStringList(value).stream().map(Integer::parseInt).toList();
}
private static Duration parseDuration(String value) {
if (value == null || value.isBlank()) {
return Duration.ofSeconds(30);
}
if (value.startsWith("P")) {
return Duration.parse(value);
}
if (value.endsWith("ms")) {
return Duration.ofMillis(Long.parseLong(value.substring(0, value.length() - 2)));
}
if (value.endsWith("s")) {
return Duration.ofSeconds(Long.parseLong(value.substring(0, value.length() - 1)));
}
if (value.endsWith("m")) {
return Duration.ofMinutes(Long.parseLong(value.substring(0, value.length() - 1)));
}
return Duration.parse(value);
}
private static String protoJson(Message message) {
try {
return JsonFormat.printer().omittingInsignificantWhitespace().print(message);
} catch (Exception error) {
throw new IllegalStateException("failed to write protobuf JSON", error);
}
}
private static String jsonObject(Map<String, Object> values) {
StringBuilder builder = new StringBuilder();
builder.append('{');
boolean first = true;
for (Map.Entry<String, Object> entry : values.entrySet()) {
if (!first) {
builder.append(',');
}
first = false;
builder.append(jsonString(entry.getKey())).append(':').append(jsonValue(entry.getValue()));
}
builder.append('}');
return builder.toString();
}
@SuppressWarnings("unchecked")
private static String jsonValue(Object value) {
if (value == null) {
return "null";
}
if (value instanceof RawJson rawJson) {
return rawJson.value();
}
if (value instanceof String string) {
return jsonString(string);
}
if (value instanceof Number || value instanceof Boolean) {
return value.toString();
}
if (value instanceof Map<?, ?> map) {
return jsonObject((Map<String, Object>) map);
}
if (value instanceof Iterable<?> iterable) {
StringBuilder builder = new StringBuilder();
builder.append('[');
boolean first = true;
for (Object item : iterable) {
if (!first) {
builder.append(',');
}
first = false;
builder.append(jsonValue(item));
}
builder.append(']');
return builder.toString();
}
return jsonString(value.toString());
}
private static String jsonString(String value) {
return '"'
+ value.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\r", "\\r")
.replace("\n", "\\n")
+ '"';
}
private record RawJson(String value) {
}
}
@@ -0,0 +1,309 @@
package com.zb.mom.ww.mxgateway.cli;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
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.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import org.junit.jupiter.api.Test;
final class MxGatewayCliTests {
@Test
void versionCommandPrintsProtocolVersions() {
CliRun run = execute(new FakeClientFactory(), "version");
assertEquals(0, run.exitCode());
assertEquals("", run.errors());
assertTrue(run.output().contains("mxgateway-java 0.1.0"));
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
assertTrue(run.output().contains("workerProtocolVersion=1"));
}
@Test
void versionCommandPrintsJson() {
CliRun run = execute(new FakeClientFactory(), "version", "--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"clientVersion\":\"0.1.0\""));
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
}
@Test
void openSessionJsonRedactsApiKey() {
CliRun run = execute(
new FakeClientFactory(),
"open-session",
"--endpoint",
"localhost:5000",
"--api-key",
"mxgw_visible_secret",
"--plaintext",
"--client-session-name",
"java-cli",
"--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"open-session\""));
assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
assertTrue(run.output().contains("mxgw***********cret"));
assertFalse(run.output().contains("visible_secret"));
}
@Test
void writeBuildsTypedValueFromParserOptions() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write",
"--session-id",
"session-cli",
"--server-handle",
"12",
"--item-handle",
"34",
"--type",
"int32",
"--value",
"123",
"--json");
assertEquals(0, run.exitCode());
assertEquals(123, factory.client.session.lastWriteValue.getInt32Value());
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE\""));
}
@Test
void smokeCommandRunsOpenRegisterAddAdviseAndClose() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(factory, "smoke", "--item", "TestObject.TestInt", "--json");
assertEquals(0, run.exitCode());
assertTrue(factory.client.session.registerCalled);
assertTrue(factory.client.session.addItemCalled);
assertTrue(factory.client.session.adviseCalled);
assertTrue(factory.client.closeCalled);
assertTrue(run.output().contains("\"serverHandle\":42"));
assertTrue(run.output().contains("\"itemHandle\":7"));
}
@Test
void subscribeBulkCommandPrintsResults() {
CliRun run = execute(
new FakeClientFactory(),
"subscribe-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--items",
"TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
"--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"subscribe-bulk\""));
assertTrue(run.output().contains("\"itemHandle\":100"));
assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
}
@Test
void unsubscribeBulkCommandPrintsResults() {
CliRun run = execute(
new FakeClientFactory(),
"unsubscribe-bulk",
"--session-id",
"session-cli",
"--server-handle",
"42",
"--item-handles",
"100,101",
"--json");
assertEquals(0, run.exitCode());
assertTrue(run.output().contains("\"command\":\"unsubscribe-bulk\""));
assertTrue(run.output().contains("\"itemHandle\":101"));
assertTrue(run.output().contains("\"wasSuccessful\":true"));
}
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
StringWriter output = new StringWriter();
StringWriter errors = new StringWriter();
int exitCode = MxGatewayCli.execute(
factory,
new PrintWriter(output, true),
new PrintWriter(errors, true),
args);
return new CliRun(exitCode, output.toString(), errors.toString());
}
private record CliRun(int exitCode, String output, String errors) {
}
private static final class FakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
private FakeClient client;
@Override
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
client = new FakeClient(options.spec.commandLine().getOut());
return client;
}
}
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
private final PrintWriter out;
private final FakeSession session = new FakeSession();
private boolean closeCalled;
private FakeClient(PrintWriter out) {
this.out = out;
}
@Override
public PrintWriter out() {
return out;
}
@Override
public OpenSessionReply openSession(OpenSessionRequest request) {
return OpenSessionReply.newBuilder()
.setSessionId("session-cli")
.setProtocolStatus(ok())
.build();
}
@Override
public CloseSessionReply closeSession(CloseSessionRequest request) {
closeCalled = true;
return CloseSessionReply.newBuilder()
.setSessionId(request.getSessionId())
.setFinalState(SessionState.SESSION_STATE_CLOSED)
.setProtocolStatus(ok())
.build();
}
@Override
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
return session;
}
@Override
public void close() {
}
}
private static final class FakeSession implements MxGatewayCli.MxGatewayCliSession {
private boolean registerCalled;
private boolean addItemCalled;
private boolean adviseCalled;
private MxValue lastWriteValue;
@Override
public int register(String clientName) {
registerCalled = true;
return 42;
}
@Override
public MxCommandReply registerRaw(String clientName) {
registerCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
.setProtocolStatus(ok())
.setRegister(RegisterReply.newBuilder().setServerHandle(42))
.build();
}
@Override
public int addItem(int serverHandle, String itemDefinition) {
addItemCalled = true;
return 7;
}
@Override
public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
addItemCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
.setProtocolStatus(ok())
.setAddItem(AddItemReply.newBuilder().setItemHandle(7))
.build();
}
@Override
public void advise(int serverHandle, int itemHandle) {
adviseCalled = true;
}
@Override
public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
adviseCalled = true;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
.setProtocolStatus(ok())
.build();
}
@Override
public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
lastWriteValue = value;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
.setProtocolStatus(ok())
.build();
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
List<SubscribeResult> results = new ArrayList<>();
for (int index = 0; index < items.size(); index++) {
results.add(SubscribeResult.newBuilder()
.setServerHandle(serverHandle)
.setTagAddress(items.get(index))
.setItemHandle(100 + index)
.setWasSuccessful(true)
.build());
}
return results;
}
@Override
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
List<SubscribeResult> results = new ArrayList<>();
for (Integer itemHandle : itemHandles) {
results.add(SubscribeResult.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setWasSuccessful(true)
.build());
}
return results;
}
@Override
public com.zb.mom.ww.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
throw new UnsupportedOperationException("stream-events is covered by client tests");
}
}
private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
.build();
}
}