diff --git a/README.md b/README.md index 3eab1c7..0f3d146 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ pods, nodes, workloads, images, networks and one-shot lambdas. - Eight command groups — `core`, `pod`, `node`, `workload`, `image`, `network`, `status`, `lambda` — covering eru core's RPCs; `GetPod`, `GetWorkload`, `GetNodeStatus`, `GetNodeEngineInfo` and `RawEngine` have no command of their own. -- Table, JSON or YAML output for every read command, selected once with `--output`. +- Table, JSON or YAML output for every read command that prints a described resource, selected once + with `--output`. - Interactive streams: `workload exec` and `lambda` attach a raw terminal, forward `SIGWINCH` and return the remote exit code as their own. - Script-friendly exit status: a batch command that acts on many workloads exits non-zero when any diff --git a/cmd/image/build.go b/cmd/image/build.go index 7527ff4..a86ee73 100644 --- a/cmd/image/build.go +++ b/cmd/image/build.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "os" - "strings" "github.com/projecteru2/core/log" corepb "github.com/projecteru2/core/rpc/gen" @@ -119,15 +118,7 @@ func generateBuildOptions(ctx context.Context, cmd *cli.Command) (*corepb.BuildI specURI := cmd.Args().First() log.WithFunc("image.generateBuildOptions").Debugf(ctx, "deploy %s", specURI) - var ( - data []byte - err error - ) - if strings.HasPrefix(specURI, "http://") || strings.HasPrefix(specURI, "https://") { - data, err = utils.GetSpecFromRemote(ctx, specURI) - } else { - data, err = os.ReadFile(specURI) //nolint:gosec - } + data, err := utils.ReadSpecURI(ctx, specURI) if err != nil { return nil, fmt.Errorf("read spec: %w", err) } diff --git a/cmd/network/cmd.go b/cmd/network/cmd.go index 4823c7b..880d4cb 100644 --- a/cmd/network/cmd.go +++ b/cmd/network/cmd.go @@ -1,6 +1,10 @@ package network import ( + "context" + "errors" + + corepb "github.com/projecteru2/core/rpc/gen" "github.com/urfave/cli/v3" "github.com/projecteru2/cli/cmd/utils" @@ -55,3 +59,21 @@ func Command() *cli.Command { }, } } + +func networkTarget(ctx context.Context, cmd *cli.Command) (corepb.CoreRPCClient, []string, string, error) { + client, err := utils.NewCoreRPCClient(ctx, cmd) + if err != nil { + return nil, nil, "", err + } + + ids := cmd.Args().Slice() + if len(ids) == 0 { + return nil, nil, "", errors.New("workload id(s) must be specified") + } + + network := cmd.String(flagNetwork) + if network == "" { + return nil, nil, "", errors.New("network must be specified") + } + return client, ids, network, nil +} diff --git a/cmd/network/connect.go b/cmd/network/connect.go index b99891e..a335b49 100644 --- a/cmd/network/connect.go +++ b/cmd/network/connect.go @@ -8,8 +8,6 @@ import ( "github.com/projecteru2/core/log" corepb "github.com/projecteru2/core/rpc/gen" "github.com/urfave/cli/v3" - - "github.com/projecteru2/cli/cmd/utils" ) type connectNetworkOptions struct { @@ -40,21 +38,11 @@ func (o *connectNetworkOptions) run(ctx context.Context) error { } func cmdNetworkConnect(ctx context.Context, cmd *cli.Command) error { - client, err := utils.NewCoreRPCClient(ctx, cmd) + client, ids, network, err := networkTarget(ctx, cmd) if err != nil { return err } - ids := cmd.Args().Slice() - if len(ids) == 0 { - return errors.New("workload id(s) must be specified") - } - - network := cmd.String(flagNetwork) - if network == "" { - return errors.New("network must be specified") - } - o := &connectNetworkOptions{ client: client, ids: ids, diff --git a/cmd/network/disconnect.go b/cmd/network/disconnect.go index 65a3535..ad07bec 100644 --- a/cmd/network/disconnect.go +++ b/cmd/network/disconnect.go @@ -8,8 +8,6 @@ import ( "github.com/projecteru2/core/log" corepb "github.com/projecteru2/core/rpc/gen" "github.com/urfave/cli/v3" - - "github.com/projecteru2/cli/cmd/utils" ) type disconnectNetworkOptions struct { @@ -35,21 +33,11 @@ func (o *disconnectNetworkOptions) run(ctx context.Context) error { } func cmdNetworkDisconnect(ctx context.Context, cmd *cli.Command) error { - client, err := utils.NewCoreRPCClient(ctx, cmd) + client, ids, network, err := networkTarget(ctx, cmd) if err != nil { return err } - ids := cmd.Args().Slice() - if len(ids) == 0 { - return errors.New("workload id(s) must be specified") - } - - network := cmd.String(flagNetwork) - if network == "" { - return errors.New("network must be specified") - } - o := &disconnectNetworkOptions{ client: client, ids: ids, diff --git a/cmd/pod/nodes.go b/cmd/pod/nodes.go index 59cea6d..ce41d56 100644 --- a/cmd/pod/nodes.go +++ b/cmd/pod/nodes.go @@ -23,7 +23,7 @@ type listPodNodesOptions struct { } func (o *listPodNodesOptions) run(ctx context.Context) error { - ch, wait, err := o.listChan(ctx, &corepb.ListNodesOptions{ + stream, err := o.client.ListPodNodes(ctx, &corepb.ListNodesOptions{ Podname: o.name, All: o.filter != up, Labels: o.labels, @@ -33,6 +33,8 @@ func (o *listPodNodesOptions) run(ctx context.Context) error { if err != nil { return err } + + ch, wait := utils.StreamToChan(stream.Recv) if o.filter == down { ch = downOnly(ch) } @@ -40,15 +42,6 @@ func (o *listPodNodesOptions) run(ctx context.Context) error { return wait() } -func (o *listPodNodesOptions) listChan(ctx context.Context, opt *corepb.ListNodesOptions) (<-chan *corepb.Node, func() error, error) { - stream, err := o.client.ListPodNodes(ctx, opt) - if err != nil { - return nil, nil, err - } - ch, wait := utils.StreamToChan(stream.Recv) - return ch, wait, nil -} - func cmdPodListNodes(ctx context.Context, cmd *cli.Command) error { client, err := utils.NewCoreRPCClient(ctx, cmd) if err != nil { diff --git a/cmd/utils/file.go b/cmd/utils/file.go index ff40c02..00e5a4d 100644 --- a/cmd/utils/file.go +++ b/cmd/utils/file.go @@ -124,3 +124,10 @@ func GetSpecFromRemote(ctx context.Context, uri string) ([]byte, error) { } return io.ReadAll(resp.Body) } + +func ReadSpecURI(ctx context.Context, uri string) ([]byte, error) { + if strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://") { + return GetSpecFromRemote(ctx, uri) + } + return os.ReadFile(uri) //nolint:gosec +} diff --git a/cmd/utils/utils.go b/cmd/utils/utils.go index 7038e7a..2ace652 100644 --- a/cmd/utils/utils.go +++ b/cmd/utils/utils.go @@ -19,10 +19,8 @@ import ( // GetNetworks returns a networkmode -> ip map. func GetNetworks(network string) map[string]string { var ip string - networkInfo := strings.Split(network, "=") - if len(networkInfo) == 2 { - network = networkInfo[0] - ip = networkInfo[1] + if name, address, ok := strings.Cut(network, "="); ok && !strings.Contains(address, "=") { + network, ip = name, address } networks := map[string]string{} if network != "" { diff --git a/cmd/workload/deploy.go b/cmd/workload/deploy.go index 066870f..3992d26 100644 --- a/cmd/workload/deploy.go +++ b/cmd/workload/deploy.go @@ -114,12 +114,7 @@ func doCreateWorkload(ctx context.Context, client corepb.CoreRPCClient, deployOp } func generateDeployOptions(ctx context.Context, cmd *cli.Command) (*corepb.DeployOptions, error) { - specs, err := loadSpecs(ctx, cmd) - if err != nil { - return nil, err - } - - entrypoint, err := entrypointOptions(specs, cmd.String(flagEntry)) + opts, specs, err := baseDeployOptions(ctx, cmd) if err != nil { return nil, err } @@ -134,14 +129,7 @@ func generateDeployOptions(ctx context.Context, cmd *cli.Command) (*corepb.Deplo return nil, fmt.Errorf("parse storage: %w", err) } - cpuRequest, cpuLimit := cpuOption(cmd) - - cpumem := resourcetypes.RawParams{ - flagCPURequest: cpuRequest, - flagCPULimit: cpuLimit, - flagMemoryRequest: memoryRequest, - flagMemoryLimit: memoryLimit, - } + cpumem := cpumemParams(cmd, memoryRequest, memoryLimit) if cmd.Bool("cpu-bind") { cpumem["cpu-bind"] = true } @@ -153,41 +141,15 @@ func generateDeployOptions(ctx context.Context, cmd *cli.Command) (*corepb.Deplo return nil, err } - files, err := utils.GenerateFileOptions(cmd) - if err != nil { - return nil, err - } - deployStrategy, err := utils.ParseDeployStrategy(cmd.String("deploy-strategy")) if err != nil { return nil, err } - return &corepb.DeployOptions{ - Name: specs.Appname, - Entrypoint: entrypoint, - Resources: resources, - Podname: cmd.String(flagPod), - NodeFilter: &corepb.NodeFilter{ - Includes: cmd.StringSlice(flagNode), - Labels: utils.SplitEquality(cmd.StringSlice("nodelabel")), - }, - Image: cmd.String(flagImage), - Count: int32(cmd.Int("count")), //nolint:gosec - Env: cmd.StringSlice(flagEnv), - Networks: utils.GetNetworks(cmd.String(flagNetwork)), - Labels: specs.Labels, - Dns: specs.DNS, - ExtraHosts: specs.ExtraHosts, - DeployStrategy: deployStrategy, - Data: files.Data, - Modes: files.Modes, - Owners: files.Owners, - User: cmd.String("user"), - Debug: cmd.Bool("debug"), - NodesLimit: int32(cmd.Int("nodes-limit")), //nolint:gosec - IgnoreHook: cmd.Bool("ignore-hook"), - AfterCreate: cmd.StringSlice("after-create"), - RawArgs: []byte(cmd.String("raw-args")), - }, nil + opts.Resources = resources + opts.NodeFilter.Labels = utils.SplitEquality(cmd.StringSlice("nodelabel")) + opts.DeployStrategy = deployStrategy + opts.NodesLimit = int32(cmd.Int("nodes-limit")) //nolint:gosec + opts.RawArgs = []byte(cmd.String("raw-args")) + return opts, nil } diff --git a/cmd/workload/dissociate.go b/cmd/workload/dissociate.go index a91ec29..ed59fb5 100644 --- a/cmd/workload/dissociate.go +++ b/cmd/workload/dissociate.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "github.com/projecteru2/core/log" corepb "github.com/projecteru2/core/rpc/gen" @@ -20,27 +21,17 @@ type dissociateWorkloadsOptions struct { func (o *dissociateWorkloadsOptions) run(ctx context.Context) error { logger := log.WithFunc("workload.dissociateWorkloadsOptions.run") - ids := make([]string, 0, len(o.ids)) - seen := map[string]struct{}{} - appendID := func(id string) { - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - ids = append(ids, id) - } - for _, id := range o.ids { - appendID(id) - } + ids := slices.Clone(o.ids) for _, node := range o.nodes { wrks, err := o.client.ListNodeWorkloads(ctx, &corepb.GetNodeOptions{Nodename: node}) if err != nil { return err } for _, wrk := range wrks.Workloads { - appendID(wrk.Id) + ids = append(ids, wrk.Id) } } + ids = slices.Compact(slices.Sorted(slices.Values(ids))) if len(ids) == 0 { return errors.New("no workloads found") } diff --git a/cmd/workload/realloc.go b/cmd/workload/realloc.go index a1f07bd..d2a09a8 100644 --- a/cmd/workload/realloc.go +++ b/cmd/workload/realloc.go @@ -78,14 +78,7 @@ func generateReallocOptions(cmd *cli.Command) (*corepb.ReallocOptions, error) { return nil, err } - cpuRequest, cpuLimit := cpuOption(cmd) - - cpumem := resourcetypes.RawParams{ - flagCPURequest: cpuRequest, - flagCPULimit: cpuLimit, - flagMemoryRequest: memoryRequest, - flagMemoryLimit: memoryLimit, - } + cpumem := cpumemParams(cmd, memoryRequest, memoryLimit) switch { case bindCPU: cpumem["cpu-bind"] = true diff --git a/cmd/workload/replace.go b/cmd/workload/replace.go index 076ae58..48f64ff 100644 --- a/cmd/workload/replace.go +++ b/cmd/workload/replace.go @@ -33,7 +33,7 @@ func cmdWorkloadReplace(ctx context.Context, cmd *cli.Command) error { return err } - opts, err := generateReplaceOptions(ctx, cmd) + opts, _, err := baseDeployOptions(ctx, cmd) if err != nil { return err } @@ -87,44 +87,3 @@ func doReplaceWorkload(ctx context.Context, client corepb.CoreRPCClient, deployO return nil }) } - -func generateReplaceOptions(ctx context.Context, cmd *cli.Command) (*corepb.DeployOptions, error) { - specs, err := loadSpecs(ctx, cmd) - if err != nil { - return nil, err - } - - entrypoint, err := entrypointOptions(specs, cmd.String(flagEntry)) - if err != nil { - return nil, err - } - - files, err := utils.GenerateFileOptions(cmd) - if err != nil { - return nil, err - } - - return &corepb.DeployOptions{ - Name: specs.Appname, - Entrypoint: entrypoint, - Podname: cmd.String(flagPod), - NodeFilter: &corepb.NodeFilter{ - Includes: cmd.StringSlice(flagNode), - }, - Image: cmd.String(flagImage), - Count: int32(cmd.Int("count")), //nolint:gosec - Env: cmd.StringSlice(flagEnv), - Networks: utils.GetNetworks(cmd.String(flagNetwork)), - Labels: specs.Labels, - Dns: specs.DNS, - ExtraHosts: specs.ExtraHosts, - DeployStrategy: corepb.DeployOptions_AUTO, - Data: files.Data, - Modes: files.Modes, - Owners: files.Owners, - User: cmd.String("user"), - Debug: cmd.Bool("debug"), - IgnoreHook: cmd.Bool("ignore-hook"), - AfterCreate: cmd.StringSlice("after-create"), - }, nil -} diff --git a/cmd/workload/utils.go b/cmd/workload/utils.go index 0f9165c..8f06af6 100644 --- a/cmd/workload/utils.go +++ b/cmd/workload/utils.go @@ -4,9 +4,9 @@ import ( "context" "errors" "fmt" - "os" "strings" + resourcetypes "github.com/projecteru2/core/resource/types" corepb "github.com/projecteru2/core/rpc/gen" "github.com/urfave/cli/v3" "gopkg.in/yaml.v3" @@ -41,15 +41,7 @@ func loadSpecs(ctx context.Context, cmd *cli.Command) (*types.Specs, error) { return nil, errors.New("a spec must be given") } - var ( - data []byte - err error - ) - if strings.HasPrefix(specURI, "http://") || strings.HasPrefix(specURI, "https://") { - data, err = utils.GetSpecFromRemote(ctx, specURI) - } else { - data, err = os.ReadFile(specURI) //nolint:gosec - } + data, err := utils.ReadSpecURI(ctx, specURI) if err != nil { return nil, err } @@ -94,6 +86,47 @@ func entrypointOptions(specs *types.Specs, entry string) (*corepb.EntrypointOpti return opts, nil } +func baseDeployOptions(ctx context.Context, cmd *cli.Command) (*corepb.DeployOptions, *types.Specs, error) { + specs, err := loadSpecs(ctx, cmd) + if err != nil { + return nil, nil, err + } + + entrypoint, err := entrypointOptions(specs, cmd.String(flagEntry)) + if err != nil { + return nil, nil, err + } + + files, err := utils.GenerateFileOptions(cmd) + if err != nil { + return nil, nil, err + } + + return &corepb.DeployOptions{ + Name: specs.Appname, + Entrypoint: entrypoint, + Podname: cmd.String(flagPod), + NodeFilter: &corepb.NodeFilter{ + Includes: cmd.StringSlice(flagNode), + }, + Image: cmd.String(flagImage), + Count: int32(cmd.Int("count")), //nolint:gosec + Env: cmd.StringSlice(flagEnv), + Networks: utils.GetNetworks(cmd.String(flagNetwork)), + Labels: specs.Labels, + Dns: specs.DNS, + ExtraHosts: specs.ExtraHosts, + DeployStrategy: corepb.DeployOptions_AUTO, + Data: files.Data, + Modes: files.Modes, + Owners: files.Owners, + User: cmd.String("user"), + Debug: cmd.Bool("debug"), + IgnoreHook: cmd.Bool("ignore-hook"), + AfterCreate: cmd.StringSlice("after-create"), + }, specs, nil +} + func ramOption(cmd *cli.Command, request, limit, shortcut string) (int64, int64, error) { req, err := utils.ParseRAMInHuman(cmd.String(request)) if err != nil { @@ -119,3 +152,13 @@ func cpuOption(cmd *cli.Command) (float64, float64) { } return cpuRequest, cpuLimit } + +func cpumemParams(cmd *cli.Command, memoryRequest, memoryLimit int64) resourcetypes.RawParams { + cpuRequest, cpuLimit := cpuOption(cmd) + return resourcetypes.RawParams{ + flagCPURequest: cpuRequest, + flagCPULimit: cpuLimit, + flagMemoryRequest: memoryRequest, + flagMemoryLimit: memoryLimit, + } +} diff --git a/describe/core.go b/describe/core.go index bc25ab6..d773a63 100644 --- a/describe/core.go +++ b/describe/core.go @@ -12,5 +12,5 @@ func describeCore(info *corepb.CoreInfo) { names := []string{"Version", "Git hash", "Built", "Golang version", "OS/Arch", "Identifier"} // Revison is misspelled in the core protobuf definition. values := []string{info.Version, info.Revison, info.BuildAt, info.GolangVersion, info.OsArch, info.Identifier} - renderTable([]string{headerName, "Description"}, names, values) + renderTable([]string{headerName, "Description"}, [][]string{names, values}) } diff --git a/describe/network.go b/describe/network.go index 729d892..478118f 100644 --- a/describe/network.go +++ b/describe/network.go @@ -17,5 +17,5 @@ func describeNetworks(networks []*corepb.Network) { nameRow = append(nameRow, network.Name) networkRow = append(networkRow, strings.Join(network.Subnets, ",")) } - renderTable([]string{headerName, "Network"}, nameRow, networkRow) + renderTable([]string{headerName, "Network"}, [][]string{nameRow, networkRow}) } diff --git a/describe/node.go b/describe/node.go index 8ad1d8f..7cec357 100644 --- a/describe/node.go +++ b/describe/node.go @@ -4,10 +4,8 @@ import ( "context" "errors" "fmt" - "os" "strings" - "github.com/jedib0t/go-pretty/v6/table" "github.com/projecteru2/core/log" resourcetypes "github.com/projecteru2/core/resource/types" corepb "github.com/projecteru2/core/rpc/gen" @@ -61,18 +59,12 @@ func renderNodes(showInfo bool, nodes ...*corepb.Node) { } names := pluginNames(capacities, usages) - header := []any{headerName, "Endpoint", "Status"} - for _, name := range names { - header = append(header, name) - } + header := append([]string{headerName, "Endpoint", "Status"}, names...) if showInfo { header = append(header, "Info") } - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) - t.AppendHeader(header) - + groups := make([][][]string, 0, len(nodes)) for i, node := range nodes { status := "DOWN" if !node.Bypass && node.Available { @@ -87,12 +79,10 @@ func renderNodes(showInfo bool, nodes ...*corepb.Node) { if showInfo { rows = append(rows, []string{node.Info}) } - t.AppendRows(toTableRows(rows)) - t.AppendSeparator() + groups = append(groups, rows) } - t.SetStyle(table.StyleLight) - t.Render() + renderTable(header, groups...) } func nodePluginRows(capacity, usage resourcetypes.RawParams) []string { @@ -121,28 +111,23 @@ func describeNodeResources(ctx context.Context, resources <-chan *corepb.NodeRes 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"}) + groups := make([][][]string, 0, len(resources)) for _, resource := range resources { cr, sr, err := ToResourcePercent(resource) if err != nil { logger.Errorf(ctx, err, "resource percent of node %s", resource.Name) continue } - rows := [][]string{ + groups = append(groups, [][]string{ {resource.Name}, {fmt.Sprintf("%.2f%%", cr["cpu"]*100)}, {fmt.Sprintf("%.2f%%", cr["memory"]*100)}, {fmt.Sprintf("%.2f%%", sr["storage"]*100)}, {fmt.Sprintf("%.2f%%", sr["volumes"]*100)}, {strings.Join(resource.Diffs, "\n")}, - } - t.AppendRows(toTableRows(rows)) - t.AppendSeparator() + }) } - t.SetStyle(table.StyleLight) - t.Render() + renderTable([]string{headerName, "Cpu", "Memory", "Storage", "Volume", "Diffs"}, groups...) } func describeNodeStatusMessage(ctx context.Context, ms []*corepb.NodeStatusStreamMessage) { diff --git a/describe/pod.go b/describe/pod.go index ccfcae6..0eaa20d 100644 --- a/describe/pod.go +++ b/describe/pod.go @@ -49,7 +49,7 @@ func describePods(pods []*corepb.Pod) { nameRow = append(nameRow, pod.Name) descRow = append(descRow, pod.Desc) } - renderTable([]string{headerName, "Description"}, nameRow, descRow) + renderTable([]string{headerName, "Description"}, [][]string{nameRow, descRow}) } func describePodCapacities(capacity *capacityOfPod) { @@ -61,5 +61,5 @@ func describePodCapacities(capacity *capacityOfPod) { nameRow = append(nameRow, node.Name) descRow = append(descRow, strconv.FormatInt(node.Capacity, 10)) } - renderTable([]string{"Node", "Capacity"}, nameRow, descRow) + renderTable([]string{"Node", "Capacity"}, [][]string{nameRow, descRow}) } diff --git a/describe/utils.go b/describe/utils.go index 1938588..b581057 100644 --- a/describe/utils.go +++ b/describe/utils.go @@ -21,17 +21,6 @@ const headerName = "Name" // Format selects the output format: json, yaml, or empty for a table. var Format string -func ToChan[T any](items ...T) chan T { - ch := make(chan T) - go func() { - defer close(ch) - for _, item := range items { - ch <- item - } - }() - return ch -} - // ToResourcePercent reports node usage as a fraction of capacity, per resource. func ToResourcePercent(resource *corepb.NodeResource) (cpumem, storage map[string]float64, err error) { var resUsage resourcetypes.Resources @@ -197,18 +186,24 @@ func describeOr[T any](v T, fallback func(T)) { } func describeChOr[T any](ch <-chan T, fallback func(<-chan T)) { - if !isJSON() && !isYAML() { - fallback(ch) - return + collect := func() []T { + items := []T{} + for t := range ch { + items = append(items, t) + } + return items } - items := []T{} - for t := range ch { - items = append(items, t) + switch { + case isJSON(): + describeAsJSON(collect()) + case isYAML(): + describeAsYAML(collect()) + default: + fallback(ch) } - describeOr(items, func([]T) {}) } -func renderTable(header []string, rows ...[]string) { +func renderTable(header []string, groups ...[][]string) { h := make(table.Row, len(header)) for i, name := range header { h[i] = name @@ -217,8 +212,10 @@ func renderTable(header []string, rows ...[]string) { t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(h) - t.AppendRows(toTableRows(rows)) - t.AppendSeparator() + for _, rows := range groups { + t.AppendRows(toTableRows(rows)) + t.AppendSeparator() + } t.SetStyle(table.StyleLight) t.Render() } diff --git a/describe/utils_test.go b/describe/utils_test.go index 4553c4b..23b06a9 100644 --- a/describe/utils_test.go +++ b/describe/utils_test.go @@ -93,6 +93,17 @@ func TestToTableRows(t *testing.T) { } } +func ToChan[T any](items ...T) chan T { + ch := make(chan T) + go func() { + defer close(ch) + for _, item := range items { + ch <- item + } + }() + return ch +} + func captureStdout(t *testing.T, f func()) string { t.Helper() r, w, err := os.Pipe() diff --git a/describe/workload.go b/describe/workload.go index bb288e6..acc4100 100644 --- a/describe/workload.go +++ b/describe/workload.go @@ -4,13 +4,11 @@ import ( "encoding/json" "fmt" "maps" - "os" "slices" "strconv" "strings" "time" - "github.com/jedib0t/go-pretty/v6/table" resourcetypes "github.com/projecteru2/core/resource/types" corepb "github.com/projecteru2/core/rpc/gen" coreutils "github.com/projecteru2/core/utils" @@ -49,11 +47,11 @@ func WorkloadStatuses(workloadStatuses ...*corepb.WorkloadStatus) { } func describeStatistics(stat workloadStatistics) { - renderTable([]string{"CPUs", "Memory", "Storage"}, - []string{fmt.Sprintf("%f", stat.CPUs)}, - []string{strconv.FormatInt(stat.Memory, 10)}, - []string{strconv.FormatInt(stat.Storage, 10)}, - ) + renderTable([]string{"CPUs", "Memory", "Storage"}, [][]string{ + {fmt.Sprintf("%f", stat.CPUs)}, + {strconv.FormatInt(stat.Memory, 10)}, + {strconv.FormatInt(stat.Storage, 10)}, + }) } func describeWorkloads(workloads []*corepb.Workload) { @@ -63,15 +61,9 @@ func describeWorkloads(workloads []*corepb.Workload) { } names := pluginNames(resources) - header := []any{"Name/ID/Pod/Node/Privileged/CreateTime", "Networks"} - for _, name := range names { - header = append(header, name) - } - - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) - t.AppendHeader(header) + header := append([]string{"Name/ID/Pod/Node/Privileged/CreateTime", "Networks"}, names...) + groups := make([][][]string, 0, len(workloads)) for i, c := range workloads { rows := [][]string{ {c.Name, c.Id, c.Podname, c.Nodename, fmt.Sprintf("Privileged: %v", c.Privileged), time.Unix(c.CreateTime, 0).UTC().Format(time.RFC3339)}, @@ -80,12 +72,10 @@ func describeWorkloads(workloads []*corepb.Workload) { for _, name := range names { rows = append(rows, parseAll(resources[i][name])) } - t.AppendRows(toTableRows(rows)) - t.AppendSeparator() + groups = append(groups, rows) } - t.SetStyle(table.StyleLight) - t.Render() + renderTable(header, groups...) } func workloadNetworks(workload *corepb.Workload) []string { @@ -120,10 +110,7 @@ func workloadNetworks(workload *corepb.Workload) []string { } func describeWorkloadStatuses(workloadStatuses []*corepb.WorkloadStatus) { - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) - t.AppendHeader(table.Row{"ID", "Status", "Networks", "Extensions"}) - + groups := make([][][]string, 0, len(workloadStatuses)) for _, s := range workloadStatuses { extensions := map[string]string{} if len(s.Extension) != 0 { @@ -132,16 +119,13 @@ func describeWorkloadStatuses(workloadStatuses []*corepb.WorkloadStatus) { } } - rows := [][]string{ + groups = append(groups, [][]string{ {s.Id}, {fmt.Sprintf("Running: %v", s.Running), fmt.Sprintf("Healthy: %v", s.Healthy)}, sortedKVLines(s.Networks), sortedKVLines(extensions), - } - t.AppendRows(toTableRows(rows)) - t.AppendSeparator() + }) } - t.SetStyle(table.StyleLight) - t.Render() + renderTable([]string{"ID", "Status", "Networks", "Extensions"}, groups...) } diff --git a/docs/cli.md b/docs/cli.md index 6ab5190..18b3e8c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,16 +13,16 @@ eru-cli [global options] [command options] [arguments...] ## Global options -Global options come before the command name and apply to all of it. +Global options may come before the command name and apply to all of it. | Option | Environment | Default | Meaning | |---|---|---|---| | `--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. `core watch` and `status` print their own line format and ignore it. | +| `--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, and so do `workload logs` and `image build`, which pass the remote bytes through. | | `--debug`, `-d` | | off | Log at debug level instead of info. | -| `--version`, `-v` | | | Print version, revision, build time and Go toolchain. | +| `--version`, `-v` | | | Print version, revision, build time, Go toolchain and OS/arch. | The table format prints a readable summary; `json` and `yaml` print the full message as core returned it and are the right choice for scripting. @@ -32,9 +32,11 @@ returned it and are the right choice for scripting. `0` means every item succeeded. Commands that act on several workloads, images or nodes in one call — `deploy`, `replace`, `send`, `sendlarge`, `copy`, `start`, `stop`, `restart`, `remove`, `dissociate`, `image cache`, `image remove`, `network connect`, `network disconnect` — report each -failure and exit non-zero if any of them failed, so a script does not need to parse the log. Any -failing command exits `255`, except `workload exec` and `lambda`, which exit with the remote -command's own code. +failure and exit non-zero if any of them failed, so a script does not need to parse the log. A +command that fails after its arguments are parsed exits `255`, except `workload exec` and `lambda`, +which exit with the remote command's own code, and `image build`, which exits with the code core +reported for the failed build. A usage error such as a missing required flag exits `1`, and an +unknown command exits `3`. ## core @@ -54,10 +56,10 @@ A pod is a named group of nodes. | `pod list` | | | | `pod add` | `` | `--desc` | | `pod remove` | `` | | -| `pod nodes` | `` | `--filter up\|down\|all` (default `all`), `--label a=1`, `--timeout 10`, `--show-info`, `--stream` | +| `pod nodes` | `` | `--filter`/`-f` `up\|down\|all` (default `all`), `--label a=1`, `--timeout 10`, `--show-info`, `--stream` | | `pod networks` | `` | `--driver` | -| `pod resource` | `` | `--filter`, `--stream` | -| `pod capacity` | `` | `--cpu`, `--memory`, `--storage`, `--cpu-bind`, `--node`, `--extra-resources` | +| `pod resource` | `` | `--filter`/`-f`, `--stream` | +| `pod capacity` | `` | `--cpu`/`-c`, `--memory`/`-m`/`--mem`, `--storage`/`-s` (all required), `--cpu-bind`, `--node`/`-n`, `--extra-resources` | `pod resource --filter` takes an expression over the usage percentages, for example `--filter "cpu > 40%"` or `--filter "memory <= 0.4"`. The attribute is one of `cpu`, `memory`, @@ -114,15 +116,15 @@ every `N` seconds; without it the status is set once. | `workload replace` | `` | `--entry`, `--image` (required), `--pod`, `--node`, `--count`, `--network`, `--network-inherit`, `--env`, `--user`, `--label`, `--file`, `--copy`, `--after-create`, `--ignore-hook`, `--debug` | | `workload get` | `...` | | | `workload list` | `[appname]` | `--entry`, `--node`, `--pod`, `--label`, `--limit`, `--match-ip`, `--skip-ip`, `--statistics` | -| `workload start`/`stop`/`restart` | `...` | `--force` | -| `workload remove` | `...` | `--force` | +| `workload start`/`stop`/`restart` | `...` | `--force`/`-f` | +| `workload remove` | `...` | `--force`/`-f` | | `workload realloc` | `` | `--cpu*`, `--memory*`, `--storage*`, `--volumes-request`, `--volumes-limit`, `--cpu-bind`, `--cpu-unbind`, `--extra-resources` | | `workload dissociate` | `...` | `--node` to take every workload on a node; returns the resources to eru without removing the workload. | -| `workload exec` | ` -- cmd...` | `--interactive`, `--env`, `--workdir` | -| `workload logs` | `` | `--tail`, `--since`, `--until`, `--follow` | +| `workload exec` | ` -- cmd...` | `--interactive`/`-i`, `--env`/`-e`, `--workdir`/`-w` | +| `workload logs` | `` | `--tail`, `--since`, `--until`, `--follow`/`-f` | | `workload get-status` | `...` | | | `workload set-status` | `...` | `--running`, `--healthy`, `--ttl`, `--network name=ip`, `--extension` | -| `workload copy` | `:path1,path2` | `--dir` (default `/tmp`) | +| `workload copy` | `:path1,path2` | `--dir`/`-d` (default `/tmp`) | | `workload send` | `...` | `--file src:dst[:mode[:uid:gid]]` | | `workload sendlarge` | `...` | `--file src:dst[:mode[:uid:gid]]`, one file per call, streamed in chunks | @@ -194,7 +196,8 @@ eru-cli lambda [options] -- cmd1 cmd2 cmd3 ``` Runs a command inside a freshly created workload, streams its output back and exits with the -command's exit code. Everything after the first positional argument is the remote command line. +command's exit code. Everything from the first positional argument onwards is the remote command +line. | Option | Default | Meaning | |---|---|---| @@ -204,18 +207,18 @@ command's exit code. Everything after the first positional argument is the remot | `--network` | | SDN network to join. | | `--count` | `1` | How many copies to run; the cli waits for all of them. | | `--cpu`, `--cpu-request` | `1`, `0` | CPU limit and request. | -| `--memory`, `--memory-request` | `512M` | Memory limit and request. | +| `--memory`, `--memory-request` | `512M`, empty | 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}}`. 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. | +| `--extra-resources` | | Extra resource plugin parameters as JSON, e.g. `{"resource-gpu":{"prod_count_map":{"nvidia-3070":1}}}`. A plugin the command's own flags already encode (cpumem on deploy, realloc and lambda; cpumem and storage on node add, node set and pod capacity) 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. | +| `--file` | | `src_path:dst_path[:mode[:uid:gid]]`, repeatable. | +| `--working-dir`, `--working_dir` | `/` | Working directory. | | `--user` | `root` | User inside the workload. | | `--privileged`, `-p` | off | Extended privileges. | | `--stdin`, `-s` | off | Attach stdin and put the terminal in raw mode. | | `--async`, `--async-timeout` | off, `30` | Return immediately and let core reap the workload. | -| `--deploy-strategy` | `auto` | `auto`, `fill`, `each`, `global`, `drained` or `dummy`. | +| `--deploy-strategy` | `AUTO` | `auto`, `fill`, `each`, `global`, `drained` or `dummy`. | | `--workload-id` | off | Prefix every output line with the workload id. | ```shell diff --git a/docs/specs.md b/docs/specs.md index c85f903..2a515ad 100644 --- a/docs/specs.md +++ b/docs/specs.md @@ -10,7 +10,7 @@ their single positional argument. The build spec — and only the build spec — is rendered as a Go text template with the process environment as its data before parsing, so `{{.CI_COMMIT_SHA}}` expands to that environment -variable. A name that is not set renders empty rather than failing. +variable. A name that is not set renders the literal `` rather than failing. ## Deploy spec