From 1f29ee604a23837bfaf079231e567126058f0a62 Mon Sep 17 00:00:00 2001 From: wangxinyi7 <121973291+wangxinyi7@users.noreply.github.com> Date: Wed, 24 Jan 2024 12:24:45 -0800 Subject: [PATCH] grpc CLI client list command (#20260) list command works --- command/registry.go | 2 + command/resource/apply-grpc/apply.go | 6 +- command/resource/helper.go | 4 +- command/resource/list-grpc/list.go | 193 +++++++++++++++++++++++ command/resource/list-grpc/list_test.go | 197 ++++++++++++++++++++++++ command/resource/resource-grpc.go | 27 ++++ 6 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 command/resource/list-grpc/list.go create mode 100644 command/resource/list-grpc/list_test.go diff --git a/command/registry.go b/command/registry.go index 38c639cd82..d3369e3cd8 100644 --- a/command/registry.go +++ b/command/registry.go @@ -118,6 +118,7 @@ import ( resourceapplygrpc "github.com/hashicorp/consul/command/resource/apply-grpc" resourcedelete "github.com/hashicorp/consul/command/resource/delete" resourcelist "github.com/hashicorp/consul/command/resource/list" + resourcelistgrpc "github.com/hashicorp/consul/command/resource/list-grpc" resourceread "github.com/hashicorp/consul/command/resource/read" resourcereadgrpc "github.com/hashicorp/consul/command/resource/read-grpc" "github.com/hashicorp/consul/command/rtt" @@ -263,6 +264,7 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { // will be refactored to resource apply entry{"resource apply-grpc", func(ui cli.Ui) (cli.Command, error) { return resourceapplygrpc.New(ui), nil }}, entry{"resource read-grpc", func(ui cli.Ui) (cli.Command, error) { return resourcereadgrpc.New(ui), nil }}, + entry{"resource list-grpc", func(ui cli.Ui) (cli.Command, error) { return resourcelistgrpc.New(ui), nil }}, entry{"resource list", func(ui cli.Ui) (cli.Command, error) { return resourcelist.New(ui), nil }}, entry{"rtt", func(ui cli.Ui) (cli.Command, error) { return rtt.New(ui), nil }}, entry{"services", func(cli.Ui) (cli.Command, error) { return services.New(), nil }}, diff --git a/command/resource/apply-grpc/apply.go b/command/resource/apply-grpc/apply.go index 28d352ae12..a4e94b41f0 100644 --- a/command/resource/apply-grpc/apply.go +++ b/command/resource/apply-grpc/apply.go @@ -134,9 +134,9 @@ Usage: consul resource apply [options] Type = gvk("group.version.kind") Name = "resource-name" Tenancy { - Namespace = "default" - Partition = "default" - PeerName = "local" + Namespace = "default" + Partition = "default" + PeerName = "local" } } diff --git a/command/resource/helper.go b/command/resource/helper.go index 5d7a19a977..30a9956796 100644 --- a/command/resource/helper.go +++ b/command/resource/helper.go @@ -162,7 +162,7 @@ func GetTypeAndResourceName(args []string) (resourceType *pbresource.Type, resou } resourceName = args[1] - resourceType, e = inferTypeFromResourceType(args[0]) + resourceType, e = InferTypeFromResourceType(args[0]) return resourceType, resourceName, e } @@ -269,7 +269,7 @@ func (resource *Resource) List(gvk *GVK, q *client.QueryOptions) (*ListResponse, return out, nil } -func inferTypeFromResourceType(resourceType string) (*pbresource.Type, error) { +func InferTypeFromResourceType(resourceType string) (*pbresource.Type, error) { s := strings.Split(resourceType, ".") switch length := len(s); { // only kind is provided diff --git a/command/resource/list-grpc/list.go b/command/resource/list-grpc/list.go new file mode 100644 index 0000000000..49cffe7df3 --- /dev/null +++ b/command/resource/list-grpc/list.go @@ -0,0 +1,193 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package list + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "strings" + + "github.com/mitchellh/cli" + + "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/consul/command/resource" + "github.com/hashicorp/consul/command/resource/client" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +func New(ui cli.Ui) *cmd { + c := &cmd{UI: ui} + c.init() + return c +} + +type cmd struct { + UI cli.Ui + flags *flag.FlagSet + grpcFlags *client.GRPCFlags + resourceFlags *client.ResourceFlags + help string + + filePath string + prefix string +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.StringVar(&c.filePath, "f", "", + "File path with resource definition") + c.flags.StringVar(&c.prefix, "p", "", + "Name prefix for listing resources if you need ambiguous match") + + c.grpcFlags = &client.GRPCFlags{} + c.resourceFlags = &client.ResourceFlags{} + client.MergeFlags(c.flags, c.grpcFlags.ClientFlags()) + client.MergeFlags(c.flags, c.resourceFlags.ResourceFlags()) + c.help = client.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + var resourceType *pbresource.Type + var resourceTenancy *pbresource.Tenancy + + if err := c.flags.Parse(args); err != nil { + if !errors.Is(err, flag.ErrHelp) { + c.UI.Error(fmt.Sprintf("Failed to parse args: %v", err)) + return 1 + } + c.UI.Error(fmt.Sprintf("Failed to run list command: %v", err)) + return 1 + } + + // collect resource type, name and tenancy + if c.flags.Lookup("f").Value.String() != "" { + if c.filePath == "" { + c.UI.Error(fmt.Sprintf("Please provide an input file with resource definition")) + return 1 + } + parsedResource, err := resource.ParseResourceFromFile(c.filePath) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to decode resource from input file: %v", err)) + return 1 + } + + if parsedResource == nil { + c.UI.Error("Unable to parse the file argument") + return 1 + } + + resourceType = parsedResource.Id.Type + resourceTenancy = parsedResource.Id.Tenancy + } else { + var err error + args := c.flags.Args() + if err = validateArgs(args); err != nil { + c.UI.Error(fmt.Sprintf("Incorrect argument format: %s", err)) + return 1 + } + resourceType, err = resource.InferTypeFromResourceType(args[0]) + if err != nil { + c.UI.Error(fmt.Sprintf("Incorrect argument format: %s", err)) + return 1 + } + + // skip resource type to parse remaining args + inputArgs := c.flags.Args()[1:] + err = resource.ParseInputParams(inputArgs, c.flags) + if err != nil { + c.UI.Error(fmt.Sprintf("Error parsing input arguments: %v", err)) + return 1 + } + if c.filePath != "" { + c.UI.Error("Incorrect argument format: File argument is not needed when resource information is provided with the command") + return 1 + } + resourceTenancy = &pbresource.Tenancy{ + Namespace: c.resourceFlags.Namespace(), + Partition: c.resourceFlags.Partition(), + PeerName: c.resourceFlags.Peername(), + } + } + + // initialize client + config, err := client.LoadGRPCConfig(nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Error loading config: %s", err)) + return 1 + } + c.grpcFlags.MergeFlagsIntoGRPCConfig(config) + resourceClient, err := client.NewGRPCClient(config) + if err != nil { + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) + return 1 + } + + // list resource + res := resource.ResourceGRPC{C: resourceClient} + entry, err := res.List(resourceType, resourceTenancy, c.prefix, c.resourceFlags.Stale()) + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing resource %s/%s: %v", resourceType, c.prefix, err)) + return 1 + } + + // display response + b, err := json.MarshalIndent(entry, "", resource.JSON_INDENT) + if err != nil { + c.UI.Error("Failed to encode output data") + return 1 + } + + c.UI.Info(string(b)) + return 0 +} + +func validateArgs(args []string) error { + if args == nil { + return fmt.Errorf("Must include resource type or flag arguments") + } + if len(args) < 1 { + return fmt.Errorf("Must include resource type argument") + } + if len(args) > 1 && !strings.HasPrefix(args[1], "-") { + return fmt.Errorf("Must include flag arguments after resource type") + } + return nil +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Lists all resources by name prefix" +const help = ` +Usage: consul resource list [type] -partition= -namespace= -peer= +or +consul resource list -f [path/to/file.hcl] + +Lists all the resources specified by the type under the given partition, namespace and peer +and outputs in JSON format. + +Example: + +$ consul resource list catalog.v2beta1.Service -p=card -partition=billing -namespace=payments -peer=eu + +$ consul resource list -f=demo.hcl -p=card + +Sample demo.hcl: + +ID { + Type = gvk("group.version.kind") + Tenancy { + Namespace = "default" + Partition = "default" + PeerName = "local" + } + } +` diff --git a/command/resource/list-grpc/list_test.go b/command/resource/list-grpc/list_test.go new file mode 100644 index 0000000000..81f4685fff --- /dev/null +++ b/command/resource/list-grpc/list_test.go @@ -0,0 +1,197 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package list + +import ( + "errors" + "fmt" + "testing" + + "github.com/mitchellh/cli" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/sdk/freeport" + "github.com/hashicorp/consul/testrpc" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/command/resource/apply-grpc" +) + +func TestResourceListCommand(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + availablePort := freeport.GetOne(t) + a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + testrpc.WaitForTestAgent(t, a.RPC, "dc1") + t.Cleanup(func() { + a.Shutdown() + }) + + applyCli := cli.NewMockUi() + + applyCmd := apply.New(applyCli) + code := applyCmd.Run([]string{ + "-f=../testdata/demo.hcl", + fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-token=root", + }) + require.Equal(t, 0, code) + require.Empty(t, applyCli.ErrorWriter.String()) + require.Contains(t, applyCli.OutputWriter.String(), "demo.v2.Artist 'korn' created.") + + cases := []struct { + name string + output string + extraArgs []string + }{ + { + name: "sample output", + output: "\"name\": \"korn\"", + extraArgs: []string{ + "demo.v2.Artist", + "-namespace=default", + "-peer=local", + "-partition=default", + }, + }, + { + name: "sample output with name prefix", + output: "\"name\": \"korn\"", + extraArgs: []string{ + "demo.v2.Artist", + "-p=korn", + "-namespace=default", + "-peer=local", + "-partition=default", + }, + }, + { + name: "file input", + output: "\"name\": \"korn\"", + extraArgs: []string{ + "-f=../testdata/demo.hcl", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + args := []string{ + fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-token=root", + } + + args = append(args, tc.extraArgs...) + + actualCode := c.Run(args) + require.Equal(t, 0, actualCode) + require.Empty(t, ui.ErrorWriter.String()) + require.Contains(t, ui.OutputWriter.String(), tc.output) + }) + } +} + +func TestResourceListInvalidArgs(t *testing.T) { + t.Parallel() + + availablePort := freeport.GetOne(t) + a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + testrpc.WaitForTestAgent(t, a.RPC, "dc1") + t.Cleanup(func() { + a.Shutdown() + }) + + type tc struct { + args []string + expectedCode int + expectedErr error + } + + cases := map[string]tc{ + "nil args": { + args: nil, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: Must include resource type or flag arguments"), + }, + "minimum args required": { + args: []string{}, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: Must include resource type argument"), + }, + "no file path": { + args: []string{ + "-f", + }, + expectedCode: 1, + expectedErr: errors.New("Failed to parse args: flag needs an argument: -f"), + }, + "file not found": { + args: []string{ + "-f=../testdata/test.hcl", + }, + expectedCode: 1, + expectedErr: errors.New("Failed to load data: Failed to read file: open ../testdata/test.hcl: no such file or directory"), + }, + "file parsing failure": { + args: []string{ + "-f=../testdata/invalid_type.hcl", + }, + expectedCode: 1, + expectedErr: errors.New("Failed to decode resource from input file"), + }, + "file argument with resource type": { + args: []string{ + "demo.v2.Artist", + "-namespace=default", + "-peer=local", + "-partition=default", + fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-token=root", + "-f=demo.hcl", + }, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: File argument is not needed when resource information is provided with the command"), + }, + "resource type invalid": { + args: []string{ + "test", + "-namespace=default", + "-peer=local", + "-partition=default", + }, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: The shorthand name does not map to any existing resource type"), + }, + "resource name is provided": { + args: []string{ + "demo.v2.Artist", + "test", + "-namespace=default", + "-peer=local", + "-partition=default", + }, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: Must include flag arguments after resource type"), + }, + } + + for desc, tc := range cases { + t.Run(desc, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + code := c.Run(tc.args) + + require.Equal(t, tc.expectedCode, code) + require.Contains(t, ui.ErrorWriter.String(), tc.expectedErr.Error()) + }) + } +} diff --git a/command/resource/resource-grpc.go b/command/resource/resource-grpc.go index 0d2b71ab4f..87eca41db2 100644 --- a/command/resource/resource-grpc.go +++ b/command/resource/resource-grpc.go @@ -68,3 +68,30 @@ func (resource *ResourceGRPC) Read(resourceType *pbresource.Type, resourceTenanc return readRsp.Resource, err } + +func (resource *ResourceGRPC) List(resourceType *pbresource.Type, resourceTenancy *pbresource.Tenancy, prefix string, stale bool) ([]*pbresource.Resource, error) { + token, err := resource.C.Config.GetToken() + if err != nil { + return nil, err + } + ctx := context.Background() + if !stale { + ctx = metadata.AppendToOutgoingContext(ctx, "x-consul-consistency-mode", "consistent") + } + if token != "" { + ctx = metadata.AppendToOutgoingContext(context.Background(), HeaderConsulToken, token) + } + + defer resource.C.Conn.Close() + listRsp, err := resource.C.Client.List(ctx, &pbresource.ListRequest{ + Type: resourceType, + Tenancy: resourceTenancy, + NamePrefix: prefix, + }) + + if err != nil { + return nil, fmt.Errorf("error listing resource: %+v", err) + } + + return listRsp.Resources, err +}