From 6188db4d204a838e54b7c8995ea7aaa0f1092ba8 Mon Sep 17 00:00:00 2001 From: wangxinyi7 <121973291+wangxinyi7@users.noreply.github.com> Date: Fri, 19 Jan 2024 19:11:41 -0800 Subject: [PATCH] grpc CLI client read command (#20178) * apply commmand works --- command/registry.go | 2 + command/resource/apply-grpc/apply.go | 11 +- .../resource/client/grpc-resource-flags.go | 50 +++++ command/resource/delete/delete.go | 9 +- command/resource/helper.go | 26 +-- command/resource/read-grpc/read.go | 172 ++++++++++++++++++ command/resource/read-grpc/read_test.go | 161 ++++++++++++++++ command/resource/read/read.go | 9 +- command/resource/resource-grpc.go | 29 +++ 9 files changed, 447 insertions(+), 22 deletions(-) create mode 100644 command/resource/client/grpc-resource-flags.go create mode 100644 command/resource/read-grpc/read.go create mode 100644 command/resource/read-grpc/read_test.go diff --git a/command/registry.go b/command/registry.go index ad795a10fb..38c639cd82 100644 --- a/command/registry.go +++ b/command/registry.go @@ -119,6 +119,7 @@ import ( resourcedelete "github.com/hashicorp/consul/command/resource/delete" resourcelist "github.com/hashicorp/consul/command/resource/list" resourceread "github.com/hashicorp/consul/command/resource/read" + resourcereadgrpc "github.com/hashicorp/consul/command/resource/read-grpc" "github.com/hashicorp/consul/command/rtt" "github.com/hashicorp/consul/command/services" svcsderegister "github.com/hashicorp/consul/command/services/deregister" @@ -261,6 +262,7 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { entry{"resource apply", func(ui cli.Ui) (cli.Command, error) { return resourceapply.New(ui), nil }}, // 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", 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 17edf76610..28d352ae12 100644 --- a/command/resource/apply-grpc/apply.go +++ b/command/resource/apply-grpc/apply.go @@ -83,25 +83,20 @@ func (c *cmd) Run(args []string) int { } // write resource - gvk := &resource.GVK{ - Group: parsedResource.Id.Type.GetGroup(), - Version: parsedResource.Id.Type.GetGroupVersion(), - Kind: parsedResource.Id.Type.GetKind(), - } res := resource.ResourceGRPC{C: resourceClient} entry, err := res.Apply(parsedResource) if err != nil { - c.UI.Error(fmt.Sprintf("Error writing resource %s/%s: %v", gvk, parsedResource.Id.GetName(), err)) + c.UI.Error(fmt.Sprintf("Error writing resource %s/%s: %v", parsedResource.Id.Type, parsedResource.Id.GetName(), err)) return 1 } // display response - b, err := json.MarshalIndent(entry, "", " ") + b, err := json.MarshalIndent(entry, "", resource.JSON_INDENT) if err != nil { c.UI.Error("Failed to encode output data") return 1 } - c.UI.Info(fmt.Sprintf("%s.%s.%s '%s' created.", gvk.Group, gvk.Version, gvk.Kind, parsedResource.Id.GetName())) + c.UI.Info(fmt.Sprintf("%s.%s.%s '%s' created.", parsedResource.Id.Type.Group, parsedResource.Id.Type.GroupVersion, parsedResource.Id.Type.Kind, parsedResource.Id.GetName())) c.UI.Info(string(b)) return 0 diff --git a/command/resource/client/grpc-resource-flags.go b/command/resource/client/grpc-resource-flags.go new file mode 100644 index 0000000000..f0146f6da6 --- /dev/null +++ b/command/resource/client/grpc-resource-flags.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package client + +import "flag" + +type ResourceFlags struct { + namespace TValue[string] + partition TValue[string] + peername TValue[string] + stale TValue[bool] +} + +func (f *ResourceFlags) ResourceFlags() *flag.FlagSet { + fs := flag.NewFlagSet("", flag.ContinueOnError) + fs.Var(&f.namespace, "namespace", + "Specifies the namespace to query. If not provided, the namespace will be inferred "+ + "from the request's ACL token, or will default to the `default` namespace.") + fs.Var(&f.partition, "partition", + "Specifies the admin partition to query. If not provided, the admin partition will be inferred "+ + "from the request's ACL token, or will default to the `default` admin partition. "+ + "Admin Partitions are a Consul Enterprise feature.") + fs.Var(&f.peername, "peer", "Specifies the name of peer to query. By default, it is `local`.") + fs.Var(&f.stale, "stale", + "Permit any Consul server (non-leader) to respond to this request. This "+ + "allows for lower latency and higher throughput, but can result in "+ + "stale data. This option has no effect on non-read operations. The "+ + "default value is false.") + return fs +} + +func (f *ResourceFlags) Namespace() string { + return f.namespace.String() +} + +func (f *ResourceFlags) Partition() string { + return f.partition.String() +} + +func (f *ResourceFlags) Peername() string { + return f.peername.String() +} + +func (f *ResourceFlags) Stale() bool { + if f.stale.v == nil { + return false + } + return *f.stale.v +} diff --git a/command/resource/delete/delete.go b/command/resource/delete/delete.go index 06421d6d1e..a084f21d0f 100644 --- a/command/resource/delete/delete.go +++ b/command/resource/delete/delete.go @@ -14,6 +14,7 @@ import ( "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 { @@ -85,7 +86,13 @@ func (c *cmd) Run(args []string) int { } } else { var err error - gvk, resourceName, err = resource.GetTypeAndResourceName(args) + var resourceType *pbresource.Type + resourceType, resourceName, err = resource.GetTypeAndResourceName(args) + gvk = &resource.GVK{ + Group: resourceType.GetGroup(), + Version: resourceType.GetGroupVersion(), + Kind: resourceType.GetKind(), + } if err != nil { c.UI.Error(fmt.Sprintf("Incorrect argument format: %s", err)) return 1 diff --git a/command/resource/helper.go b/command/resource/helper.go index 9b2f64a108..5d7a19a977 100644 --- a/command/resource/helper.go +++ b/command/resource/helper.go @@ -24,6 +24,8 @@ import ( "github.com/hashicorp/consul/proto-public/pbresource" ) +const JSON_INDENT = " " + type OuterResource struct { ID *ID `json:"id"` Owner *ID `json:"owner"` @@ -150,7 +152,7 @@ func ParseInputParams(inputArgs []string, flags *flag.FlagSet) error { return nil } -func GetTypeAndResourceName(args []string) (gvk *GVK, resourceName string, e error) { +func GetTypeAndResourceName(args []string) (resourceType *pbresource.Type, resourceName string, e error) { if len(args) < 2 { return nil, "", fmt.Errorf("Must specify two arguments: resource type and resource name") } @@ -160,9 +162,9 @@ func GetTypeAndResourceName(args []string) (gvk *GVK, resourceName string, e err } resourceName = args[1] - gvk, e = inferGVKFromResourceType(args[0]) + resourceType, e = inferTypeFromResourceType(args[0]) - return + return resourceType, resourceName, e } type Resource struct { @@ -267,7 +269,7 @@ func (resource *Resource) List(gvk *GVK, q *client.QueryOptions) (*ListResponse, return out, nil } -func inferGVKFromResourceType(resourceType string) (*GVK, error) { +func inferTypeFromResourceType(resourceType string) (*pbresource.Type, error) { s := strings.Split(resourceType, ".") switch length := len(s); { // only kind is provided @@ -282,20 +284,20 @@ func inferGVKFromResourceType(resourceType string) (*GVK, error) { case 1: // infer gvk from resource kind gvkSplit := strings.Split(kindToGVKMap[kind][0], ".") - return &GVK{ - Group: gvkSplit[0], - Version: gvkSplit[1], - Kind: gvkSplit[2], + return &pbresource.Type{ + Group: gvkSplit[0], + GroupVersion: gvkSplit[1], + Kind: gvkSplit[2], }, nil // it alerts error if any conflict is found default: return nil, fmt.Errorf("The shorthand name has conflicts %v, please use the full name", kindToGVKMap[s[0]]) } case length == 3: - return &GVK{ - Group: s[0], - Version: s[1], - Kind: s[2], + return &pbresource.Type{ + Group: s[0], + GroupVersion: s[1], + Kind: s[2], }, nil default: return nil, fmt.Errorf("Must provide resource type argument with either in group.verion.kind format or its shorthand name") diff --git a/command/resource/read-grpc/read.go b/command/resource/read-grpc/read.go new file mode 100644 index 0000000000..e73e62ec92 --- /dev/null +++ b/command/resource/read-grpc/read.go @@ -0,0 +1,172 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package read + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + + "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 +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.StringVar(&c.filePath, "f", "", + "File path with resource definition") + + 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 + var resourceName string + + 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 read 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("The parsed resource is nil") + return 1 + } + + resourceType = parsedResource.Id.Type + resourceTenancy = parsedResource.Id.Tenancy + resourceName = parsedResource.Id.Name + } else { + var err error + resourceType, resourceName, err = resource.GetTypeAndResourceName(args) + if err != nil { + c.UI.Error(fmt.Sprintf("Incorrect argument format: %s", err)) + return 1 + } + + inputArgs := args[2:] + 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 connecting to Consul agent: %s", err)) + return 1 + } + + // read resource + res := resource.ResourceGRPC{C: resourceClient} + entry, err := res.Read(resourceType, resourceTenancy, resourceName, c.resourceFlags.Stale()) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading resource %s/%s: %v", resourceType, resourceName, 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 (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Read resource information" +const help = ` +Usage: You have two options to read the resource specified by the given +type, name, partition, namespace and peer and outputs its JSON representation. + +consul resource read [type] [name] -partition= -namespace= -peer= +consul resource read -f [resource_file_path] + +But you could only use one of the approaches. + +Example: + +$ consul resource read catalog.v2beta1.Service card-processor -partition=billing -namespace=payments -peer=eu +$ consul resource read -f resource.hcl + +In resource.hcl, it could be: +ID { + Type = gvk("catalog.v2beta1.Service") + Name = "card-processor" + Tenancy { + Namespace = "payments" + Partition = "billing" + PeerName = "eu" + } +} +` diff --git a/command/resource/read-grpc/read_test.go b/command/resource/read-grpc/read_test.go new file mode 100644 index 0000000000..a6858af659 --- /dev/null +++ b/command/resource/read-grpc/read_test.go @@ -0,0 +1,161 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 +package read + +import ( + "errors" + "fmt" + "testing" + + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/command/resource/apply-grpc" + "github.com/hashicorp/consul/sdk/freeport" + "github.com/hashicorp/consul/testrpc" +) + +func TestResourceReadInvalidArgs(t *testing.T) { + t.Parallel() + + 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 specify two arguments: resource type and resource name"), + }, + "empty args": { + args: []string{}, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: Must specify two arguments: resource type and resource name"), + }, + "missing 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"), + }, + "provide type and name": { + args: []string{"a.b.c"}, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: Must specify two arguments: resource type and resource name"), + }, + "provide type and name with -f": { + args: []string{"a.b.c", "name", "-f", "test.hcl"}, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: File argument is not needed when resource information is provided with the command"), + }, + "provide type and name with -f and other flags": { + args: []string{"a.b.c", "name", "-f", "test.hcl", "-namespace", "default"}, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: File argument is not needed when resource information is provided with the command"), + }, + "does not provide resource name after type": { + args: []string{"a.b.c", "-namespace", "default"}, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: Must provide resource name right after type"), + }, + "invalid resource type format": { + args: []string{"a.", "name", "-namespace", "default"}, + expectedCode: 1, + expectedErr: errors.New("Incorrect argument format: Must provide resource type argument with either in group.verion.kind format or its shorthand name"), + }, + } + + 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()) + }) + } +} + +func createResource(t *testing.T, port int) { + applyUi := cli.NewMockUi() + applyCmd := apply.New(applyUi) + + args := []string{ + fmt.Sprintf("-grpc-addr=127.0.0.1:%d", port), + "-token=root", + } + + args = append(args, []string{"-f=../testdata/demo.hcl"}...) + + code := applyCmd.Run(args) + require.Equal(t, 0, code) + require.Empty(t, applyUi.ErrorWriter.String()) +} + +func TestResourceRead(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() + }) + + defaultCmdArgs := []string{ + fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-token=root", + } + + createResource(t, availablePort) + cases := []struct { + name string + args []string + expectedCode int + errMsg string + }{ + { + name: "read resource in hcl format", + args: []string{"-f=../testdata/demo.hcl"}, + expectedCode: 0, + errMsg: "", + }, + { + name: "read resource in command line format", + args: []string{"demo.v2.Artist", "korn", "-partition=default", "-namespace=default", "-peer=local"}, + expectedCode: 0, + errMsg: "", + }, + { + name: "read resource that doesn't exist", + args: []string{"demo.v2.Artist", "fake-korn", "-partition=default", "-namespace=default", "-peer=local"}, + expectedCode: 1, + errMsg: "error reading resource: rpc error: code = NotFound desc = resource not found\n", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + cliArgs := append(tc.args, defaultCmdArgs...) + code := c.Run(cliArgs) + require.Contains(t, ui.ErrorWriter.String(), tc.errMsg) + require.Equal(t, tc.expectedCode, code) + }) + } +} diff --git a/command/resource/read/read.go b/command/resource/read/read.go index a38889ad14..aaefc2a5aa 100644 --- a/command/resource/read/read.go +++ b/command/resource/read/read.go @@ -15,6 +15,7 @@ import ( "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 { @@ -87,7 +88,13 @@ func (c *cmd) Run(args []string) int { } } else { var err error - gvk, resourceName, err = resource.GetTypeAndResourceName(args) + var resourceType *pbresource.Type + resourceType, resourceName, err = resource.GetTypeAndResourceName(args) + gvk = &resource.GVK{ + Group: resourceType.GetGroup(), + Version: resourceType.GetGroupVersion(), + Kind: resourceType.GetKind(), + } if err != nil { c.UI.Error(fmt.Sprintf("Incorrect argument format: %s", err)) return 1 diff --git a/command/resource/resource-grpc.go b/command/resource/resource-grpc.go index 510742074e..0d2b71ab4f 100644 --- a/command/resource/resource-grpc.go +++ b/command/resource/resource-grpc.go @@ -39,3 +39,32 @@ func (resource *ResourceGRPC) Apply(parsedResource *pbresource.Resource) (*pbres return writeRsp.Resource, err } + +func (resource *ResourceGRPC) Read(resourceType *pbresource.Type, resourceTenancy *pbresource.Tenancy, resourceName 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() + readRsp, err := resource.C.Client.Read(ctx, &pbresource.ReadRequest{ + Id: &pbresource.ID{ + Type: resourceType, + Tenancy: resourceTenancy, + Name: resourceName, + }, + }) + + if err != nil { + return nil, fmt.Errorf("error reading resource: %+v", err) + } + + return readRsp.Resource, err +}