From 8cb416ba301b6622797e412a876d8887d6e3607c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 10:00:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(go):=20add=20galaxy-browse=20CLI=20subcomm?= =?UTF-8?q?and=20(=C2=A74.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clients/go/cmd/mxgw-go/main.go | 229 +++++++++++++++++++++++++++- clients/go/cmd/mxgw-go/main_test.go | 140 +++++++++++++++++ clients/go/mxgateway/galaxy.go | 5 + 3 files changed, 373 insertions(+), 1 deletion(-) diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go index 3604d40..70499bd 100644 --- a/clients/go/cmd/mxgw-go/main.go +++ b/clients/go/cmd/mxgw-go/main.go @@ -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 "galaxy-browse": + return runGalaxyBrowse(ctx, args[1:], stdout, stderr) case "ping": return runPing(ctx, args[1:], stdout, stderr) case "batch": @@ -1244,7 +1246,7 @@ type protojsonMessage interface { } func writeUsage(writer io.Writer) { - fmt.Fprintln(writer, "usage: mxgw-go ") + fmt.Fprintln(writer, "usage: mxgw-go ") } // batchEOR is the end-of-result sentinel emitted to stdout after every command @@ -1507,6 +1509,231 @@ 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 *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, hasChildren=%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["hasChildren"] = 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 { diff --git a/clients/go/cmd/mxgw-go/main_test.go b/clients/go/cmd/mxgw-go/main_test.go index efbe9c4..34e6239 100644 --- a/clients/go/cmd/mxgw-go/main_test.go +++ b/clients/go/cmd/mxgw-go/main_test.go @@ -348,6 +348,146 @@ func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) { } } +// 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. diff --git a/clients/go/mxgateway/galaxy.go b/clients/go/mxgateway/galaxy.go index 3df6d97..3a3a339 100644 --- a/clients/go/mxgateway/galaxy.go +++ b/clients/go/mxgateway/galaxy.go @@ -54,6 +54,11 @@ type ( 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.