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.
This commit is contained in:
Joseph Doherty
2026-06-15 09:41:40 -04:00
parent a211faefed
commit 90529dce6e
3 changed files with 151 additions and 1 deletions
+44 -1
View File
@@ -121,6 +121,8 @@ 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 "ping":
return runPing(ctx, args[1:], stdout, stderr)
case "batch":
return runBatch(ctx, os.Stdin, stdout, stderr)
default:
@@ -228,6 +230,47 @@ 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),
})
}
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)
@@ -1196,7 +1239,7 @@ 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|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|batch>")
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|batch>")
}
// batchEOR is the end-of-result sentinel emitted to stdout after every command
+96
View File
@@ -190,6 +190,102 @@ func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
}
}
// 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")
}
}
// 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).
+11
View File
@@ -580,6 +580,17 @@ 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)