From db4738d48ed77bd4d82d165a260cc31ca4ec1e6a Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 3 Sep 2026 02:51:17 +0800 Subject: [PATCH 1/3] fix: close the cli audit items An empty result renders the header-only table on every read command. Nodes a command already holds are described from a slice; the channel form is NodesStream and node resource has a single-item form, so no caller fabricates a channel. pod nodes --filter down filters the stream instead of buffering it, so --stream means the same for every filter. image build asks whether stdout is a terminal once. image list leaves the pod-or-node precondition to core like cache and remove. node add sends share as a number. docs/cli.md says which commands ignore --output and how --extra-resources meets a command's own flags. --- cmd/image/build.go | 3 +- cmd/image/list.go | 5 ---- cmd/node/add.go | 5 ++-- cmd/node/add_test.go | 8 +++--- cmd/node/get.go | 2 +- cmd/node/resource.go | 2 +- cmd/pod/nodes.go | 62 ++++++++++++------------------------------ describe/image.go | 7 ----- describe/image_test.go | 5 ++-- describe/node.go | 47 ++++++++++++++++++++------------ describe/node_test.go | 10 +++---- describe/workload.go | 4 --- docs/cli.md | 4 +-- 13 files changed, 68 insertions(+), 96 deletions(-) diff --git a/cmd/image/build.go b/cmd/image/build.go index 2f50140..7527ff4 100644 --- a/cmd/image/build.go +++ b/cmd/image/build.go @@ -32,6 +32,7 @@ func (o *buildImageOptions) run(ctx context.Context) error { return err } + interactive := term.IsTerminal(int(os.Stdout.Fd())) progress := map[string]int{} p := 0 for { @@ -61,7 +62,7 @@ func (o *buildImageOptions) run(ctx context.Context) error { progress[msg.Id] = p fmt.Println(data) p++ - } else if term.IsTerminal(int(os.Stdout.Fd())) { + } else if interactive { fmt.Printf(progressRewrite, p-pos, data) } else { fmt.Println(data) diff --git a/cmd/image/list.go b/cmd/image/list.go index 9cc8b38..576bebf 100644 --- a/cmd/image/list.go +++ b/cmd/image/list.go @@ -2,7 +2,6 @@ package image import ( "context" - "errors" "fmt" corepb "github.com/projecteru2/core/rpc/gen" @@ -58,10 +57,6 @@ func generateListOptions(cmd *cli.Command) (*corepb.ListImageOptions, error) { filter := cmd.String("filter") podname := cmd.String(flagPod) nodename := cmd.StringSlice(flagNode) - if len(nodename) == 0 && podname == "" { - return nil, errors.New("podname or nodenames should be given") - } - return &corepb.ListImageOptions{ Podname: podname, Nodenames: nodename, diff --git a/cmd/node/add.go b/cmd/node/add.go index 4b3f18c..6f041d2 100644 --- a/cmd/node/add.go +++ b/cmd/node/add.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strconv" "strings" resourcetypes "github.com/projecteru2/core/resource/types" @@ -26,7 +25,7 @@ func (o *addNodeOptions) run(ctx context.Context) error { return err } - describe.Nodes(describe.ToChan(node), false, false) + describe.Nodes(false, node) return nil } @@ -66,7 +65,7 @@ func generateAddNodeOptions(cmd *cli.Command) (*corepb.AddNodeOptions, error) { cpumem["cpu"] = cmd.Int("cpu") } if cmd.IsSet("share") { - cpumem["share"] = strconv.Itoa(cmd.Int("share")) + cpumem["share"] = cmd.Int("share") } resources, err := utils.EncodeResources(cmd, resourcetypes.Resources{ diff --git a/cmd/node/add_test.go b/cmd/node/add_test.go index 171eec8..2da3472 100644 --- a/cmd/node/add_test.go +++ b/cmd/node/add_test.go @@ -18,7 +18,7 @@ func TestGenerateAddNodeOptions(t *testing.T) { name string args []string wantCPU int64 - wantShare string + wantShare int64 wantMemory string wantStorage string wantVolumes []string @@ -27,7 +27,7 @@ func TestGenerateAddNodeOptions(t *testing.T) { name: "cpu and share", args: []string{"node", "add", "--endpoint", "process://127.0.0.1", "--cpu", "64", "--share", "100", "dev"}, wantCPU: 64, - wantShare: "100", + wantShare: 100, }, { name: "memory storage and volumes", @@ -49,8 +49,8 @@ func TestGenerateAddNodeOptions(t *testing.T) { if got := cpumem.Int64("cpu"); got != tt.wantCPU { t.Errorf("cpu: got %d, want %d", got, tt.wantCPU) } - if got := cpumem.String("share"); got != tt.wantShare { - t.Errorf("share: got %q, want %q", got, tt.wantShare) + if got := cpumem.Int64("share"); got != tt.wantShare { + t.Errorf("share: got %d, want %d", got, tt.wantShare) } if got := cpumem.String("memory"); got != tt.wantMemory { t.Errorf("memory: got %q, want %q", got, tt.wantMemory) diff --git a/cmd/node/get.go b/cmd/node/get.go index 5321bf2..7e59b4a 100644 --- a/cmd/node/get.go +++ b/cmd/node/get.go @@ -24,7 +24,7 @@ func (o *getNodeOptions) run(ctx context.Context) error { return err } - describe.Nodes(describe.ToChan(node), true, false) + describe.Nodes(true, node) return nil } diff --git a/cmd/node/resource.go b/cmd/node/resource.go index 563720b..c88a98c 100644 --- a/cmd/node/resource.go +++ b/cmd/node/resource.go @@ -27,7 +27,7 @@ func (o *nodeResourceOptions) run(ctx context.Context) error { return err } - describe.NodeResources(ctx, describe.ToChan(resource), false) + describe.NodeResource(ctx, resource) return nil } diff --git a/cmd/pod/nodes.go b/cmd/pod/nodes.go index 67c6a09..59cea6d 100644 --- a/cmd/pod/nodes.go +++ b/cmd/pod/nodes.go @@ -3,7 +3,6 @@ package pod import ( "context" "errors" - "slices" "strings" corepb "github.com/projecteru2/core/rpc/gen" @@ -24,35 +23,9 @@ type listPodNodesOptions struct { } func (o *listPodNodesOptions) run(ctx context.Context) error { - if o.filter == up || o.filter == all { - return o.listUpOrAll(ctx) - } - return o.listDown(ctx) -} - -func (o *listPodNodesOptions) listDown(ctx context.Context) error { - allNodes, err := o.list(ctx, &corepb.ListNodesOptions{ - Podname: o.name, - All: true, - Labels: o.labels, - TimeoutInSecond: o.timeoutInSecond, - SkipInfo: !o.showInfo, - }) - if err != nil { - return err - } - - downNodes := slices.DeleteFunc(allNodes, func(node *corepb.Node) bool { - return node.Available && !node.Bypass - }) - describe.Nodes(describe.ToChan(downNodes...), o.showInfo, o.stream) - return nil -} - -func (o *listPodNodesOptions) listUpOrAll(ctx context.Context) error { ch, wait, err := o.listChan(ctx, &corepb.ListNodesOptions{ Podname: o.name, - All: o.filter == all, + All: o.filter != up, Labels: o.labels, TimeoutInSecond: o.timeoutInSecond, SkipInfo: !o.showInfo, @@ -60,23 +33,11 @@ func (o *listPodNodesOptions) listUpOrAll(ctx context.Context) error { if err != nil { return err } - - describe.Nodes(ch, o.showInfo, o.stream) - - return wait() -} - -func (o *listPodNodesOptions) list(ctx context.Context, opt *corepb.ListNodesOptions) ([]*corepb.Node, error) { - ch, wait, err := o.listChan(ctx, opt) - if err != nil { - return nil, err - } - - nodes := []*corepb.Node{} - for n := range ch { - nodes = append(nodes, n) + if o.filter == down { + ch = downOnly(ch) } - return nodes, wait() + describe.NodesStream(ch, o.showInfo, o.stream) + return wait() } func (o *listPodNodesOptions) listChan(ctx context.Context, opt *corepb.ListNodesOptions) (<-chan *corepb.Node, func() error, error) { @@ -110,3 +71,16 @@ func cmdPodListNodes(ctx context.Context, cmd *cli.Command) error { } return o.run(ctx) } + +func downOnly(nodes <-chan *corepb.Node) <-chan *corepb.Node { + down := make(chan *corepb.Node) + go func() { + defer close(down) + for node := range nodes { + if !node.Available || node.Bypass { + down <- node + } + } + }() + return down +} diff --git a/describe/image.go b/describe/image.go index e4385a6..27e251d 100644 --- a/describe/image.go +++ b/describe/image.go @@ -1,9 +1,7 @@ package describe import ( - "fmt" "os" - "slices" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" @@ -15,11 +13,6 @@ func Images(msgs ...*corepb.ListImageMessage) { } func describeImages(msgs []*corepb.ListImageMessage) { - if !slices.ContainsFunc(msgs, func(msg *corepb.ListImageMessage) bool { return len(msg.Images) > 0 }) { - fmt.Println("no images") - return - } - t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Node", "Image", "Tags"}) diff --git a/describe/image_test.go b/describe/image_test.go index f611b8e..522ecdb 100644 --- a/describe/image_test.go +++ b/describe/image_test.go @@ -75,8 +75,9 @@ func TestImagesWithoutAnyImage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := captureStdout(t, func() { Images(tt.msgs...) }); got != "no images\n" { - t.Errorf("got %q, want %q", got, "no images\n") + want := "┌──────┬───────┬──────┐\n│ NODE │ IMAGE │ TAGS │\n├──────┼───────┼──────┤\n└──────┴───────┴──────┘\n" + if got := captureStdout(t, func() { Images(tt.msgs...) }); got != want { + t.Errorf("got %q, want the header-only table", got) } }) } diff --git a/describe/node.go b/describe/node.go index b259e7b..8ad1d8f 100644 --- a/describe/node.go +++ b/describe/node.go @@ -13,10 +13,21 @@ import ( corepb "github.com/projecteru2/core/rpc/gen" ) -func Nodes(nodes <-chan *corepb.Node, showInfo, stream bool) { +// Nodes describes nodes a command already holds. +func Nodes(showInfo bool, nodes ...*corepb.Node) { + describeOr(nodes, func(all []*corepb.Node) { renderNodes(showInfo, all...) }) +} + +// NodesStream describes nodes as they arrive, one table per node when stream is set. +func NodesStream(nodes <-chan *corepb.Node, showInfo, stream bool) { describeChOr(nodes, func(ch <-chan *corepb.Node) { describeNodes(ch, showInfo, stream) }) } +// NodeResource describes one node's resource. +func NodeResource(ctx context.Context, resource *corepb.NodeResource) { + describeOr(resource, func(r *corepb.NodeResource) { renderNodeResources(ctx, r) }) +} + func NodeResources(ctx context.Context, resources <-chan *corepb.NodeResource, stream bool) { describeChOr(resources, func(ch <-chan *corepb.NodeResource) { describeNodeResources(ctx, ch, stream) }) } @@ -42,10 +53,6 @@ func describeNodes(nodes <-chan *corepb.Node, showInfo, stream bool) { } func renderNodes(showInfo bool, nodes ...*corepb.Node) { - if len(nodes) == 0 { - return - } - capacities := make([]resourcetypes.Resources, len(nodes)) usages := make([]resourcetypes.Resources, len(nodes)) for i, node := range nodes { @@ -99,12 +106,25 @@ func nodePluginRows(capacity, usage resourcetypes.RawParams) []string { } func describeNodeResources(ctx context.Context, resources <-chan *corepb.NodeResource, stream bool) { - logger := log.WithFunc("describe.describeNodeResources") + if stream { + for resource := range resources { + renderNodeResources(ctx, resource) + } + return + } + all := []*corepb.NodeResource{} + for resource := range resources { + all = append(all, resource) + } + renderNodeResources(ctx, all...) +} + +func renderNodeResources(ctx context.Context, resources ...*corepb.NodeResource) { + logger := log.WithFunc("describe.renderNodeResources") t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{headerName, "Cpu", "Memory", "Storage", "Volume", "Diffs"}) - - for resource := range resources { + for _, resource := range resources { cr, sr, err := ToResourcePercent(resource) if err != nil { logger.Errorf(ctx, err, "resource percent of node %s", resource.Name) @@ -120,16 +140,9 @@ func describeNodeResources(ctx context.Context, resources <-chan *corepb.NodeRes } t.AppendRows(toTableRows(rows)) t.AppendSeparator() - if stream { - t.SetStyle(table.StyleLight) - t.Render() - t.ResetRows() - } - } - if !stream { - t.SetStyle(table.StyleLight) - t.Render() } + t.SetStyle(table.StyleLight) + t.Render() } func describeNodeStatusMessage(ctx context.Context, ms []*corepb.NodeStatusStreamMessage) { diff --git a/describe/node_test.go b/describe/node_test.go index d312131..44da567 100644 --- a/describe/node_test.go +++ b/describe/node_test.go @@ -80,7 +80,7 @@ func TestNodes(t *testing.T) { Format = tt.format t.Cleanup(func() { Format = "" }) - if got := captureStdout(t, func() { Nodes(ToChan(testNodes()...), false, false) }); got != tt.want { + if got := captureStdout(t, func() { NodesStream(ToChan(testNodes()...), false, false) }); got != tt.want { t.Errorf("got\n%s\nwant\n%s", got, tt.want) } }) @@ -114,7 +114,7 @@ func TestNodesWithInfo(t *testing.T) { │ │ │ │ memory: 1024 │ │ └───────┴─────────────────────┴─────────────────┴──────────────┴───────────────────────┘ ` - got := captureStdout(t, func() { Nodes(ToChan(testNodes()...), true, false) }) + got := captureStdout(t, func() { NodesStream(ToChan(testNodes()...), true, false) }) if got != want { t.Errorf("got\n%s\nwant\n%s", got, want) } @@ -195,7 +195,7 @@ func TestNodesStream(t *testing.T) { │ │ │ │ memory: 1024 │ └───────┴─────────────────────┴─────────────────┴──────────────┘ ` - got := captureStdout(t, func() { Nodes(ToChan(testNodes()...), false, true) }) + got := captureStdout(t, func() { NodesStream(ToChan(testNodes()...), false, true) }) if got != want { t.Errorf("got\n%s\nwant\n%s", got, want) } @@ -340,10 +340,10 @@ func TestNodesPluginColumns(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := captureStdout(t, func() { if tt.showInfo { - Nodes(ToChan(tt.nodes...), true, false) + NodesStream(ToChan(tt.nodes...), true, false) return } - Nodes(ToChan(tt.nodes...), false, false) + NodesStream(ToChan(tt.nodes...), false, false) }) if got != tt.want { t.Errorf("got\n%s\nwant\n%s", got, tt.want) diff --git a/describe/workload.go b/describe/workload.go index c683488..bb288e6 100644 --- a/describe/workload.go +++ b/describe/workload.go @@ -57,10 +57,6 @@ func describeStatistics(stat workloadStatistics) { } func describeWorkloads(workloads []*corepb.Workload) { - if len(workloads) == 0 { - return - } - resources := make([]resourcetypes.Resources, len(workloads)) for i, workload := range workloads { resources[i] = unmarshalResources(workload.Resources) diff --git a/docs/cli.md b/docs/cli.md index b614090..6ab5190 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,7 +20,7 @@ Global options come before the command name and apply to all of it. | `--eru`, `-e` | `ERU` | `127.0.0.1:5001` | Address of the eru core to call. | | `--username`, `-u` | `ERU_USERNAME` | empty | Username when core requires authentication. | | `--password`, `-p` | `ERU_PASSWORD` | empty | Password when core requires authentication. | -| `--output`, `-o` | `ERU_OUTPUT_FORMAT` | empty | `json`, `yaml`, or empty for a table. | +| `--output`, `-o` | `ERU_OUTPUT_FORMAT` | empty | `json`, `yaml`, or empty for a table. `core watch` and `status` print their own line format and ignore it. | | `--debug`, `-d` | | off | Log at debug level instead of info. | | `--version`, `-v` | | | Print version, revision, build time and Go toolchain. | @@ -207,7 +207,7 @@ command's exit code. Everything after the first positional argument is the remot | `--memory`, `--memory-request` | `512M` | Memory limit and request. | | `--storage`, `--storage-request` | | Storage limit and request. | | `--volume`, `--volume-request` | | Volume limit and request, repeatable. | -| `--extra-resources` | | Extra resource plugin parameters as JSON, e.g. `{"gpu":{"count":1}}`. | +| `--extra-resources` | | Extra resource plugin parameters as JSON, e.g. `{"gpu":{"count":1}}`. A plugin the command's own flags already encode (cpumem on deploy, realloc, lambda and capacity; cpumem and storage on node set) keeps the flag values; the JSON fills in only the plugins the flags left empty. | | `--env` | | `KEY=value`, repeatable. | | `--file` | | `src:dst`, repeatable. | | `--working-dir` | `/` | Working directory. | From ad9909325fac89b65832580636daccabd9965128 Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 3 Sep 2026 02:54:59 +0800 Subject: [PATCH 2/3] review: format the add node test --- cmd/node/add_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/node/add_test.go b/cmd/node/add_test.go index 2da3472..77d26e8 100644 --- a/cmd/node/add_test.go +++ b/cmd/node/add_test.go @@ -18,7 +18,7 @@ func TestGenerateAddNodeOptions(t *testing.T) { name string args []string wantCPU int64 - wantShare int64 + wantShare int64 wantMemory string wantStorage string wantVolumes []string From 936d79a08a0d7691b6b83cd034c3d7038bc096fa Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 3 Sep 2026 03:25:44 +0800 Subject: [PATCH 3/3] review: the image list options never fail to build --- cmd/image/list.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/cmd/image/list.go b/cmd/image/list.go index 576bebf..4f0027b 100644 --- a/cmd/image/list.go +++ b/cmd/image/list.go @@ -41,25 +41,17 @@ func cmdImageList(ctx context.Context, cmd *cli.Command) error { return err } - opts, err := generateListOptions(cmd) - if err != nil { - return err - } - o := &listImageOptions{ client: client, - opts: opts, + opts: generateListOptions(cmd), } return o.run(ctx) } -func generateListOptions(cmd *cli.Command) (*corepb.ListImageOptions, error) { - filter := cmd.String("filter") - podname := cmd.String(flagPod) - nodename := cmd.StringSlice(flagNode) +func generateListOptions(cmd *cli.Command) *corepb.ListImageOptions { return &corepb.ListImageOptions{ - Podname: podname, - Nodenames: nodename, - Filter: filter, - }, nil + Podname: cmd.String(flagPod), + Nodenames: cmd.StringSlice(flagNode), + Filter: cmd.String("filter"), + } }