feat(go): add galaxy-browse CLI subcommand (§4.6)
This commit is contained in:
@@ -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 <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>")
|
||||
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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user