From b1bd6ab91a94ade23d224ec3062406ec54a664c8 Mon Sep 17 00:00:00 2001 From: wangxinyi7 Date: Fri, 23 Feb 2024 12:27:49 -0800 Subject: [PATCH] revert grpc to http (#20716) * Revert "refactor the resource client (#20343)" This reverts commit 3c5cb04b0f4e399e57d9cde72d4a4270a9798fe3. * Revert "clean up http client (#20342)" This reverts commit 2b89025eabd6240b0cd4f3e6f0f327531567e339. * remove deprecated peer * fix the typo * remove forwarding test as it tests grpc, should add it back --- command/registry.go | 15 +- command/resource/apply-grpc/apply.go | 150 +++ command/resource/apply-grpc/apply_test.go | 226 ++++ command/resource/apply/apply.go | 128 +- command/resource/apply/apply_test.go | 84 +- command/resource/client/client.go | 1081 +++++++++++++++-- command/resource/client/grpc-client.go | 65 + .../{client_test.go => grpc-client_test.go} | 0 .../client/{config.go => grpc-config.go} | 0 .../{config_test.go => grpc-config_test.go} | 0 ...source-flags.go => grpc-resource-flags.go} | 0 command/resource/client/helper.go | 206 ---- command/resource/client/helper_test.go | 25 - command/resource/delete-grpc/delete.go | 163 +++ command/resource/delete-grpc/delete_test.go | 164 +++ command/resource/delete/delete.go | 126 +- command/resource/delete/delete_test.go | 18 +- command/resource/helper.go | 322 +++++ command/resource/helper_test.go | 34 + command/resource/list-grpc/list.go | 192 +++ command/resource/list-grpc/list_test.go | 192 +++ command/resource/list/list.go | 156 +-- command/resource/list/list_test.go | 48 +- command/resource/read-grpc/read.go | 171 +++ command/resource/read-grpc/read_test.go | 161 +++ command/resource/read/read.go | 130 +- command/resource/read/read_test.go | 21 +- command/resource/resource-grpc.go | 123 ++ command/resource/resource.go | 4 - .../test/resource/grpc_forwarding_test.go | 188 --- 30 files changed, 3307 insertions(+), 886 deletions(-) create mode 100644 command/resource/apply-grpc/apply.go create mode 100644 command/resource/apply-grpc/apply_test.go create mode 100644 command/resource/client/grpc-client.go rename command/resource/client/{client_test.go => grpc-client_test.go} (100%) rename command/resource/client/{config.go => grpc-config.go} (100%) rename command/resource/client/{config_test.go => grpc-config_test.go} (100%) rename command/resource/client/{resource-flags.go => grpc-resource-flags.go} (100%) create mode 100644 command/resource/delete-grpc/delete.go create mode 100644 command/resource/delete-grpc/delete_test.go create mode 100644 command/resource/helper.go create mode 100644 command/resource/helper_test.go create mode 100644 command/resource/list-grpc/list.go create mode 100644 command/resource/list-grpc/list_test.go create mode 100644 command/resource/read-grpc/read.go create mode 100644 command/resource/read-grpc/read_test.go create mode 100644 command/resource/resource-grpc.go delete mode 100644 test/integration/consul-container/test/resource/grpc_forwarding_test.go diff --git a/command/registry.go b/command/registry.go index a735e75a1d..ca0baeeb31 100644 --- a/command/registry.go +++ b/command/registry.go @@ -115,9 +115,13 @@ import ( "github.com/hashicorp/consul/command/reload" "github.com/hashicorp/consul/command/resource" resourceapply "github.com/hashicorp/consul/command/resource/apply" + resourceapplygrpc "github.com/hashicorp/consul/command/resource/apply-grpc" resourcedelete "github.com/hashicorp/consul/command/resource/delete" + resourcedeletegrpc "github.com/hashicorp/consul/command/resource/delete-grpc" 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" "github.com/hashicorp/consul/command/services" svcsderegister "github.com/hashicorp/consul/command/services/deregister" @@ -256,10 +260,15 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { entry{"peering read", func(ui cli.Ui) (cli.Command, error) { return peerread.New(ui), nil }}, entry{"reload", func(ui cli.Ui) (cli.Command, error) { return reload.New(ui), nil }}, entry{"resource", func(cli.Ui) (cli.Command, error) { return resource.New(), nil }}, - entry{"resource apply", func(ui cli.Ui) (cli.Command, error) { return resourceapply.New(ui), nil }}, - entry{"resource delete", func(ui cli.Ui) (cli.Command, error) { return resourcedelete.New(ui), nil }}, - entry{"resource list", func(ui cli.Ui) (cli.Command, error) { return resourcelist.New(ui), nil }}, entry{"resource read", func(ui cli.Ui) (cli.Command, error) { return resourceread.New(ui), nil }}, + entry{"resource delete", func(ui cli.Ui) (cli.Command, error) { return resourcedelete.New(ui), nil }}, + 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-grpc", func(ui cli.Ui) (cli.Command, error) { return resourcelistgrpc.New(ui), nil }}, + entry{"resource delete-grpc", func(ui cli.Ui) (cli.Command, error) { return resourcedeletegrpc.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 }}, entry{"services register", func(ui cli.Ui) (cli.Command, error) { return svcsregister.New(ui), nil }}, diff --git a/command/resource/apply-grpc/apply.go b/command/resource/apply-grpc/apply.go new file mode 100644 index 0000000000..1819730c48 --- /dev/null +++ b/command/resource/apply-grpc/apply.go @@ -0,0 +1,150 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package apply + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + + "github.com/mitchellh/cli" + + "github.com/hashicorp/consul/command/resource" + "github.com/hashicorp/consul/command/resource/client" +) + +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 + help string + + filePath string + + testStdin io.Reader +} + +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{} + client.MergeFlags(c.flags, c.grpcFlags.ClientFlags()) + c.help = client.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + 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 apply command: %v", err)) + return 1 + } + + // parse resource + input := c.filePath + if input == "" { + c.UI.Error("Required '-f' flag was not provided to specify where to load the resource content from") + return 1 + } + parsedResource, err := resource.ParseResourceInput(input, c.testStdin) + 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 + } + + // 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 + } + + // write resource + res := resource.ResourceGRPC{C: resourceClient} + entry, err := res.Apply(parsedResource) + if err != nil { + 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, "", 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.", parsedResource.Id.Type.Group, parsedResource.Id.Type.GroupVersion, parsedResource.Id.Type.Kind, parsedResource.Id.GetName())) + c.UI.Info(string(b)) + + return 0 +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return client.Usage(c.help, nil) +} + +const synopsis = "Writes/updates resource information" + +const help = ` +Usage: consul resource apply [options] + + Write and/or update a resource by providing the definition. The configuration + argument is either a file path or '-' to indicate that the resource + should be read from stdin. The data should be either in HCL or + JSON form. + + Example (with flag): + + $ consul resource apply -f=demo.hcl + + Example (from stdin): + + $ consul resource apply -f - < demo.hcl + + Sample demo.hcl: + + ID { + Type = gvk("group.version.kind") + Name = "resource-name" + Tenancy { + Partition = "default" + Namespace = "default" + PeerName = "local" + } + } + + Data { + Name = "demo" + } + + Metadata = { + "foo" = "bar" + } +` diff --git a/command/resource/apply-grpc/apply_test.go b/command/resource/apply-grpc/apply_test.go new file mode 100644 index 0000000000..7b338a0283 --- /dev/null +++ b/command/resource/apply-grpc/apply_test.go @@ -0,0 +1,226 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package apply + +import ( + "errors" + "fmt" + "io" + "testing" + + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/sdk/freeport" + "github.com/hashicorp/consul/testrpc" +) + +func TestResourceApplyCommand(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() + }) + + cases := []struct { + name string + output string + args []string + }{ + { + name: "sample output", + args: []string{"-f=../testdata/demo.hcl"}, + output: "demo.v2.Artist 'korn' created.", + }, + { + name: "nested data format", + args: []string{"-f=../testdata/nested_data.hcl"}, + output: "mesh.v2beta1.Destinations 'api' created.", + }, + } + + 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.args...) + + code := c.Run(args) + require.Equal(t, 0, code) + require.Empty(t, ui.ErrorWriter.String()) + require.Contains(t, ui.OutputWriter.String(), tc.output) + }) + } +} + +func TestResourceApplyCommand_StdIn(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() + }) + + t.Run("hcl", func(t *testing.T) { + stdinR, stdinW := io.Pipe() + + ui := cli.NewMockUi() + c := New(ui) + c.testStdin = stdinR + + stdInput := `ID { + Type = gvk("demo.v2.Artist") + Name = "korn" + Tenancy { + Partition = "default" + Namespace = "default" + } + } + + Data { + Name = "Korn" + Genre = "GENRE_METAL" + } + + Metadata = { + "foo" = "bar" + }` + + go func() { + stdinW.Write([]byte(stdInput)) + stdinW.Close() + }() + + args := []string{ + fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-token=root", + "-f", + "-", + } + + code := c.Run(args) + require.Equal(t, 0, code) + require.Empty(t, ui.ErrorWriter.String()) + // Todo: make up the read result check after finishing the read command + //expected := readResource(t, a, []string{"demo.v2.Artist", "korn"}) + require.Contains(t, ui.OutputWriter.String(), "demo.v2.Artist 'korn' created.") + //require.Contains(t, ui.OutputWriter.String(), expected) + }) + + t.Run("json", func(t *testing.T) { + stdinR, stdinW := io.Pipe() + + ui := cli.NewMockUi() + c := New(ui) + c.testStdin = stdinR + + stdInput := `{ + "data": { + "genre": "GENRE_METAL", + "name": "Korn" + }, + "id": { + "name": "korn", + "tenancy": { + "partition": "default", + "namespace": "default" + }, + "type": { + "group": "demo", + "groupVersion": "v2", + "kind": "Artist" + } + }, + "metadata": { + "foo": "bar" + } + }` + + go func() { + stdinW.Write([]byte(stdInput)) + stdinW.Close() + }() + + args := []string{ + "-f", + "-", + fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-token=root", + } + + code := c.Run(args) + require.Equal(t, 0, code) + require.Empty(t, ui.ErrorWriter.String()) + // Todo: make up the read result check after finishing the read command + //expected := readResource(t, a, []string{"demo.v2.Artist", "korn"}) + require.Contains(t, ui.OutputWriter.String(), "demo.v2.Artist 'korn' created.") + //require.Contains(t, ui.OutputWriter.String(), expected) + }) +} + +func TestResourceApplyInvalidArgs(t *testing.T) { + t.Parallel() + + type tc struct { + args []string + expectedCode int + expectedErr error + } + + cases := map[string]tc{ + "no file path": { + args: []string{"-f"}, + expectedCode: 1, + expectedErr: errors.New("Failed to parse args: flag needs an argument: -f"), + }, + "missing required flag": { + args: []string{}, + expectedCode: 1, + expectedErr: errors.New("Required '-f' flag was not provided to specify where to load the resource content from"), + }, + "file parsing failure": { + args: []string{"-f=../testdata/invalid.hcl"}, + expectedCode: 1, + expectedErr: errors.New("Failed to decode resource from input file"), + }, + "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"), + }, + } + + 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/apply/apply.go b/command/resource/apply/apply.go index a435d43fc8..cf92af9474 100644 --- a/command/resource/apply/apply.go +++ b/command/resource/apply/apply.go @@ -11,8 +11,13 @@ import ( "io" "github.com/mitchellh/cli" + "google.golang.org/protobuf/encoding/protojson" + "github.com/hashicorp/consul/api" + "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 { @@ -22,10 +27,10 @@ func New(ui cli.Ui) *cmd { } type cmd struct { - UI cli.Ui - flags *flag.FlagSet - grpcFlags *client.GRPCFlags - help string + UI cli.Ui + flags *flag.FlagSet + http *flags.HTTPFlags + help string filePath string @@ -37,9 +42,31 @@ func (c *cmd) init() { c.flags.StringVar(&c.filePath, "f", "", "File path with resource definition") - c.grpcFlags = &client.GRPCFlags{} - client.MergeFlags(c.flags, c.grpcFlags.ClientFlags()) - c.help = client.Usage(help, c.flags) + c.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + c.help = flags.Usage(help, c.flags) +} + +func makeWriteRequest(parsedResource *pbresource.Resource) (payload *resource.WriteRequest, error error) { + // The parsed hcl file has data field in proto message format anypb.Any + // Converting to json format requires us to fisrt marshal it then unmarshal it + data, err := protojson.Marshal(parsedResource.Data) + if err != nil { + return nil, fmt.Errorf("unrecognized hcl format: %s", err) + } + + var resourceData map[string]any + err = json.Unmarshal(data, &resourceData) + if err != nil { + return nil, fmt.Errorf("unrecognized hcl format: %s", err) + } + delete(resourceData, "@type") + + return &resource.WriteRequest{ + Data: resourceData, + Metadata: parsedResource.GetMetadata(), + Owner: parsedResource.GetOwner(), + }, nil } func (c *cmd) Run(args []string) int { @@ -48,55 +75,76 @@ func (c *cmd) Run(args []string) int { c.UI.Error(fmt.Sprintf("Failed to parse args: %v", err)) return 1 } - c.UI.Error(fmt.Sprintf("Failed to run apply command: %v", err)) + } + + input := c.filePath + + if input == "" && len(c.flags.Args()) > 0 { + input = c.flags.Arg(0) + } + + var parsedResource *pbresource.Resource + + if input != "" { + data, err := resource.ParseResourceInput(input, c.testStdin) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to decode resource from input file: %v", err)) + return 1 + } + parsedResource = data + } else { + c.UI.Error("Incorrect argument format: Must provide exactly one positional argument to specify the resource to write") return 1 } - // parse resource - input := c.filePath - if input == "" { - c.UI.Error("Required '-f' flag was not provided to specify where to load the resource content from") - return 1 - } - parsedResource, err := client.ParseResourceInput(input, c.testStdin) - 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 } - // 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) + config := api.DefaultConfig() + + c.http.MergeOntoConfig(config) + resourceClient, err := client.NewClient(config) if err != nil { c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) return 1 } - // write resource - entry, err := resourceClient.Apply(parsedResource) + res := resource.Resource{C: resourceClient} + + opts := &client.QueryOptions{ + Namespace: parsedResource.Id.Tenancy.GetNamespace(), + Partition: parsedResource.Id.Tenancy.GetPartition(), + Token: c.http.Token(), + } + + gvk := &resource.GVK{ + Group: parsedResource.Id.Type.GetGroup(), + Version: parsedResource.Id.Type.GetGroupVersion(), + Kind: parsedResource.Id.Type.GetKind(), + } + + writeRequest, err := makeWriteRequest(parsedResource) if err != nil { - c.UI.Error(fmt.Sprintf("Error writing resource %s/%s: %v", parsedResource.Id.Type, parsedResource.Id.GetName(), err)) + c.UI.Error(fmt.Sprintf("Error parsing hcl input: %v", err)) return 1 } - // display response - b, err := json.MarshalIndent(entry, "", client.JSON_INDENT) + entry, err := res.Apply(gvk, parsedResource.Id.GetName(), opts, writeRequest) + if err != nil { + c.UI.Error(fmt.Sprintf("Error writing resource %s/%s: %v", gvk, parsedResource.Id.GetName(), err)) + return 1 + } + + b, err := json.MarshalIndent(entry, "", " ") if err != nil { c.UI.Error("Failed to encode output data") return 1 } - 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)) + c.UI.Info(fmt.Sprintf("%s.%s.%s '%s' created.", gvk.Group, gvk.Version, gvk.Kind, parsedResource.Id.GetName())) + c.UI.Info(string(b)) return 0 } @@ -105,7 +153,7 @@ func (c *cmd) Synopsis() string { } func (c *cmd) Help() string { - return client.Usage(c.help, nil) + return flags.Usage(c.help, nil) } const synopsis = "Writes/updates resource information" @@ -122,9 +170,13 @@ Usage: consul resource apply [options] $ consul resource apply -f=demo.hcl + Example (from file): + + $ consul resource apply demo.hcl + Example (from stdin): - $ consul resource apply -f - < demo.hcl + $ consul resource apply - Sample demo.hcl: @@ -132,8 +184,8 @@ Usage: consul resource apply [options] Type = gvk("group.version.kind") Name = "resource-name" Tenancy { - Namespace = "default" - Partition = "default" + Namespace = "default" + Partition = "default" } } diff --git a/command/resource/apply/apply_test.go b/command/resource/apply/apply_test.go index 5689c8c26f..a6cb1b6929 100644 --- a/command/resource/apply/apply_test.go +++ b/command/resource/apply/apply_test.go @@ -5,7 +5,6 @@ package apply import ( "errors" - "fmt" "io" "testing" @@ -14,7 +13,6 @@ import ( "github.com/hashicorp/consul/agent" "github.com/hashicorp/consul/command/resource/read" - "github.com/hashicorp/consul/sdk/freeport" "github.com/hashicorp/consul/testrpc" ) @@ -24,14 +22,10 @@ func TestResourceApplyCommand(t *testing.T) { } t.Parallel() - availablePort := freeport.GetOne(t) - a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() testrpc.WaitForTestAgent(t, a.RPC, "dc1") - t.Cleanup(func() { - a.Shutdown() - }) - cases := []struct { name string output string @@ -47,6 +41,11 @@ func TestResourceApplyCommand(t *testing.T) { args: []string{"-f=../testdata/nested_data.hcl"}, output: "mesh.v2beta1.Destinations 'api' created.", }, + { + name: "file path with no flag", + args: []string{"../testdata/nested_data.hcl"}, + output: "mesh.v2beta1.Destinations 'api' created.", + }, } for _, tc := range cases { @@ -55,7 +54,7 @@ func TestResourceApplyCommand(t *testing.T) { c := New(ui) args := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-http-addr=" + a.HTTPAddr(), "-token=root", } @@ -69,6 +68,23 @@ func TestResourceApplyCommand(t *testing.T) { } } +func readResource(t *testing.T, a *agent.TestAgent, extraArgs []string) string { + readUi := cli.NewMockUi() + readCmd := read.New(readUi) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-token=root", + } + + args = append(extraArgs, args...) + + code := readCmd.Run(args) + require.Equal(t, 0, code) + require.Empty(t, readUi.ErrorWriter.String()) + return readUi.OutputWriter.String() +} + func TestResourceApplyCommand_StdIn(t *testing.T) { if testing.Short() { t.Skip("too slow for testing.Short") @@ -76,14 +92,10 @@ func TestResourceApplyCommand_StdIn(t *testing.T) { t.Parallel() - availablePort := freeport.GetOne(t) - a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() testrpc.WaitForTestAgent(t, a.RPC, "dc1") - t.Cleanup(func() { - a.Shutdown() - }) - t.Run("hcl", func(t *testing.T) { stdinR, stdinW := io.Pipe() @@ -95,8 +107,8 @@ func TestResourceApplyCommand_StdIn(t *testing.T) { Type = gvk("demo.v2.Artist") Name = "korn" Tenancy { - Partition = "default" Namespace = "default" + Partition = "default" } } @@ -115,18 +127,17 @@ func TestResourceApplyCommand_StdIn(t *testing.T) { }() args := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-http-addr=" + a.HTTPAddr(), "-token=root", - "-f", "-", } code := c.Run(args) require.Equal(t, 0, code, ui.ErrorWriter.String()) require.Empty(t, ui.ErrorWriter.String()) + expected := readResource(t, a, []string{"demo.v2.Artist", "korn"}) require.Contains(t, ui.OutputWriter.String(), "demo.v2.Artist 'korn' created.") - readUI := readResource(t, []string{"demo.v2.Artist", "korn"}, availablePort) - require.Contains(t, ui.OutputWriter.String(), readUI.OutputWriter.String()) + require.Contains(t, ui.OutputWriter.String(), expected) }) t.Run("json", func(t *testing.T) { @@ -144,8 +155,8 @@ func TestResourceApplyCommand_StdIn(t *testing.T) { "id": { "name": "korn", "tenancy": { - "partition": "default", - "namespace": "default" + "namespace": "default", + "partition": "default" }, "type": { "group": "demo", @@ -164,18 +175,17 @@ func TestResourceApplyCommand_StdIn(t *testing.T) { }() args := []string{ - "-f", - "-", - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-http-addr=" + a.HTTPAddr(), "-token=root", + "-", } code := c.Run(args) require.Equal(t, 0, code, ui.ErrorWriter.String()) require.Empty(t, ui.ErrorWriter.String()) + expected := readResource(t, a, []string{"demo.v2.Artist", "korn"}) require.Contains(t, ui.OutputWriter.String(), "demo.v2.Artist 'korn' created.") - readUI := readResource(t, []string{"demo.v2.Artist", "korn"}, availablePort) - require.Contains(t, ui.OutputWriter.String(), readUI.OutputWriter.String()) + require.Contains(t, ui.OutputWriter.String(), expected) }) } @@ -197,7 +207,7 @@ func TestResourceApplyInvalidArgs(t *testing.T) { "missing required flag": { args: []string{}, expectedCode: 1, - expectedErr: errors.New("Required '-f' flag was not provided to specify where to load the resource content from"), + expectedErr: errors.New("Incorrect argument format: Must provide exactly one positional argument to specify the resource to write"), }, "file parsing failure": { args: []string{"-f=../testdata/invalid.hcl"}, @@ -223,21 +233,3 @@ func TestResourceApplyInvalidArgs(t *testing.T) { }) } } - -func readResource(t *testing.T, args []string, port int) *cli.MockUi { - readUi := cli.NewMockUi() - readCmd := read.New(readUi) - - flags := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", port), - "-token=root", - } - - args = append(args, flags...) - - code := readCmd.Run(args) - require.Equal(t, 0, code) - require.Empty(t, readUi.ErrorWriter.String()) - - return readUi -} diff --git a/command/resource/client/client.go b/command/resource/client/client.go index c841e7b0f4..7113e4e587 100644 --- a/command/resource/client/client.go +++ b/command/resource/client/client.go @@ -1,172 +1,999 @@ // Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: MPL-2.0 package client import ( + "bytes" "context" + "encoding/json" "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" + "github.com/hashicorp/go-cleanhttp" + "github.com/hashicorp/go-hclog" - "github.com/hashicorp/consul/proto-public/pbresource" + "github.com/hashicorp/consul/api" ) +// NOTE: This client is copied from the api module to temporarily facilitate the resource cli commands + const ( - HeaderConsulToken = "x-consul-token" + // HTTPAddrEnvName defines an environment variable name which sets + // the HTTP address if there is no -http-addr specified. + HTTPAddrEnvName = "CONSUL_HTTP_ADDR" + + // HTTPTokenEnvName defines an environment variable name which sets + // the HTTP token. + HTTPTokenEnvName = "CONSUL_HTTP_TOKEN" + + // HTTPTokenFileEnvName defines an environment variable name which sets + // the HTTP token file. + HTTPTokenFileEnvName = "CONSUL_HTTP_TOKEN_FILE" + + // HTTPAuthEnvName defines an environment variable name which sets + // the HTTP authentication header. + HTTPAuthEnvName = "CONSUL_HTTP_AUTH" + + // HTTPSSLEnvName defines an environment variable name which sets + // whether or not to use HTTPS. + HTTPSSLEnvName = "CONSUL_HTTP_SSL" + + // HTTPCAFile defines an environment variable name which sets the + // CA file to use for talking to Consul over TLS. + HTTPCAFile = "CONSUL_CACERT" + + // HTTPCAPath defines an environment variable name which sets the + // path to a directory of CA certs to use for talking to Consul over TLS. + HTTPCAPath = "CONSUL_CAPATH" + + // HTTPClientCert defines an environment variable name which sets the + // client cert file to use for talking to Consul over TLS. + HTTPClientCert = "CONSUL_CLIENT_CERT" + + // HTTPClientKey defines an environment variable name which sets the + // client key file to use for talking to Consul over TLS. + HTTPClientKey = "CONSUL_CLIENT_KEY" + + // HTTPTLSServerName defines an environment variable name which sets the + // server name to use as the SNI host when connecting via TLS + HTTPTLSServerName = "CONSUL_TLS_SERVER_NAME" + + // HTTPSSLVerifyEnvName defines an environment variable name which sets + // whether or not to disable certificate checking. + HTTPSSLVerifyEnvName = "CONSUL_HTTP_SSL_VERIFY" + + // HTTPNamespaceEnvVar defines an environment variable name which sets + // the HTTP Namespace to be used by default. This can still be overridden. + HTTPNamespaceEnvName = "CONSUL_NAMESPACE" + + // HTTPPartitionEnvName defines an environment variable name which sets + // the HTTP Partition to be used by default. This can still be overridden. + HTTPPartitionEnvName = "CONSUL_PARTITION" + + // QueryBackendStreaming Query backend of type streaming + QueryBackendStreaming = "streaming" + + // QueryBackendBlockingQuery Query backend of type blocking query + QueryBackendBlockingQuery = "blocking-query" ) -type GRPCClient struct { - Client pbresource.ResourceServiceClient - Config *GRPCConfig - Conn *grpc.ClientConn +type StatusError struct { + Code int + Body string } -func NewGRPCClient(config *GRPCConfig) (*GRPCClient, error) { - conn, err := dial(config) - if err != nil { - return nil, fmt.Errorf("error dialing grpc: %+v", err) - } - return &GRPCClient{ - Client: pbresource.NewResourceServiceClient(conn), - Config: config, - Conn: conn, - }, nil +func (e StatusError) Error() string { + return fmt.Sprintf("Unexpected response code: %d (%s)", e.Code, e.Body) } -func (client *GRPCClient) Apply(parsedResource *pbresource.Resource) (*pbresource.Resource, error) { - token, err := client.Config.GetToken() - if err != nil { - return nil, err - } - ctx := context.Background() - if token != "" { - ctx = metadata.AppendToOutgoingContext(ctx, HeaderConsulToken, token) - } +// QueryOptions are used to parameterize a query +type QueryOptions struct { + // Namespace overrides the `default` namespace + // Note: Namespaces are available only in Consul Enterprise + Namespace string - defer client.Conn.Close() - writeRsp, err := client.Client.Write(ctx, &pbresource.WriteRequest{Resource: parsedResource}) - if err != nil { - return nil, fmt.Errorf("error writing resource: %+v", err) - } + // Partition overrides the `default` partition + // Note: Partitions are available only in Consul Enterprise + Partition string - return writeRsp.Resource, err + // Providing a datacenter overwrites the DC provided + // by the Config + Datacenter string + + // AllowStale allows any Consul server (non-leader) to service + // a read. This allows for lower latency and higher throughput + AllowStale bool + + // RequireConsistent forces the read to be fully consistent. + // This is more expensive but prevents ever performing a stale + // read. + RequireConsistent bool + + // UseCache requests that the agent cache results locally. See + // https://www.consul.io/api/features/caching.html for more details on the + // semantics. + UseCache bool + + // MaxAge limits how old a cached value will be returned if UseCache is true. + // If there is a cached response that is older than the MaxAge, it is treated + // as a cache miss and a new fetch invoked. If the fetch fails, the error is + // returned. Clients that wish to allow for stale results on error can set + // StaleIfError to a longer duration to change this behavior. It is ignored + // if the endpoint supports background refresh caching. See + // https://www.consul.io/api/features/caching.html for more details. + MaxAge time.Duration + + // StaleIfError specifies how stale the client will accept a cached response + // if the servers are unavailable to fetch a fresh one. Only makes sense when + // UseCache is true and MaxAge is set to a lower, non-zero value. It is + // ignored if the endpoint supports background refresh caching. See + // https://www.consul.io/api/features/caching.html for more details. + StaleIfError time.Duration + + // WaitIndex is used to enable a blocking query. Waits + // until the timeout or the next index is reached + WaitIndex uint64 + + // WaitHash is used by some endpoints instead of WaitIndex to perform blocking + // on state based on a hash of the response rather than a monotonic index. + // This is required when the state being blocked on is not stored in Raft, for + // example agent-local proxy configuration. + WaitHash string + + // WaitTime is used to bound the duration of a wait. + // Defaults to that of the Config, but can be overridden. + WaitTime time.Duration + + // Token is used to provide a per-request ACL token + // which overrides the agent's default token. + Token string + + // Near is used to provide a node name that will sort the results + // in ascending order based on the estimated round trip time from + // that node. Setting this to "_agent" will use the agent's node + // for the sort. + Near string + + // NodeMeta is used to filter results by nodes with the given + // metadata key/value pairs. Currently, only one key/value pair can + // be provided for filtering. + NodeMeta map[string]string + + // RelayFactor is used in keyring operations to cause responses to be + // relayed back to the sender through N other random nodes. Must be + // a value from 0 to 5 (inclusive). + RelayFactor uint8 + + // LocalOnly is used in keyring list operation to force the keyring + // query to only hit local servers (no WAN traffic). + LocalOnly bool + + // Connect filters prepared query execution to only include Connect-capable + // services. This currently affects prepared query execution. + Connect bool + + // ctx is an optional context pass through to the underlying HTTP + // request layer. Use Context() and WithContext() to manage this. + ctx context.Context + + // Filter requests filtering data prior to it being returned. The string + // is a go-bexpr compatible expression. + Filter string + + // MergeCentralConfig returns a service definition merged with the + // proxy-defaults/global and service-defaults/:service config entries. + // This can be used to ensure a full service definition is returned in the response + // especially when the service might not be written into the catalog that way. + MergeCentralConfig bool + + // Global is used to request information from all datacenters. Currently only + // used for operator usage requests. + Global bool } -func (client *GRPCClient) Read(resourceType *pbresource.Type, resourceTenancy *pbresource.Tenancy, resourceName string, stale bool) (*pbresource.Resource, error) { - token, err := client.Config.GetToken() - if err != nil { - return nil, err +func (o *QueryOptions) Context() context.Context { + if o != nil && o.ctx != nil { + return o.ctx } - ctx := context.Background() - if !stale { - ctx = metadata.AppendToOutgoingContext(ctx, "x-consul-consistency-mode", "consistent") - } - if token != "" { - ctx = metadata.AppendToOutgoingContext(ctx, HeaderConsulToken, token) - } - - defer client.Conn.Close() - readRsp, err := client.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 + return context.Background() } -func (client *GRPCClient) List(resourceType *pbresource.Type, resourceTenancy *pbresource.Tenancy, prefix string, stale bool) ([]*pbresource.Resource, error) { - token, err := client.Config.GetToken() - if err != nil { - return nil, err +func (o *QueryOptions) WithContext(ctx context.Context) *QueryOptions { + o2 := new(QueryOptions) + if o != nil { + *o2 = *o } - ctx := context.Background() - if !stale { - ctx = metadata.AppendToOutgoingContext(ctx, "x-consul-consistency-mode", "consistent") - } - if token != "" { - ctx = metadata.AppendToOutgoingContext(context.Background(), HeaderConsulToken, token) - } - - defer client.Conn.Close() - listRsp, err := client.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 + o2.ctx = ctx + return o2 } -func (client *GRPCClient) Delete(resourceType *pbresource.Type, resourceTenancy *pbresource.Tenancy, resourceName string) error { - token, err := client.Config.GetToken() - if err != nil { - return err - } - ctx := context.Background() - if token != "" { - ctx = metadata.AppendToOutgoingContext(context.Background(), HeaderConsulToken, token) - } +// WriteOptions are used to parameterize a write +type WriteOptions struct { + // Namespace overrides the `default` namespace + // Note: Namespaces are available only in Consul Enterprise + Namespace string - defer client.Conn.Close() - _, err = client.Client.Delete(ctx, &pbresource.DeleteRequest{ - Id: &pbresource.ID{ - Type: resourceType, - Tenancy: resourceTenancy, - Name: resourceName, - }, - }) + // Partition overrides the `default` partition + // Note: Partitions are available only in Consul Enterprise + Partition string - if err != nil { - return fmt.Errorf("error deleting resource: %+v", err) - } + // Providing a datacenter overwrites the DC provided + // by the Config + Datacenter string - return nil + // Token is used to provide a per-request ACL token + // which overrides the agent's default token. + Token string + + // RelayFactor is used in keyring operations to cause responses to be + // relayed back to the sender through N other random nodes. Must be + // a value from 0 to 5 (inclusive). + RelayFactor uint8 + + // ctx is an optional context pass through to the underlying HTTP + // request layer. Use Context() and WithContext() to manage this. + ctx context.Context } -func dial(c *GRPCConfig) (*grpc.ClientConn, error) { - err := checkCertificates(c) - if err != nil { - return nil, err +func (o *WriteOptions) Context() context.Context { + if o != nil && o.ctx != nil { + return o.ctx } - var dialOpts []grpc.DialOption - if c.GRPCTLS { - tlsConfig, err := SetupTLSConfig(c) + return context.Background() +} + +func (o *WriteOptions) WithContext(ctx context.Context) *WriteOptions { + o2 := new(WriteOptions) + if o != nil { + *o2 = *o + } + o2.ctx = ctx + return o2 +} + +// QueryMeta is used to return meta data about a query +type QueryMeta struct { + // LastIndex. This can be used as a WaitIndex to perform + // a blocking query + LastIndex uint64 + + // LastContentHash. This can be used as a WaitHash to perform a blocking query + // for endpoints that support hash-based blocking. Endpoints that do not + // support it will return an empty hash. + LastContentHash string + + // Time of last contact from the leader for the + // server servicing the request + LastContact time.Duration + + // Is there a known leader + KnownLeader bool + + // How long did the request take + RequestTime time.Duration + + // Is address translation enabled for HTTP responses on this agent + AddressTranslationEnabled bool + + // CacheHit is true if the result was served from agent-local cache. + CacheHit bool + + // CacheAge is set if request was ?cached and indicates how stale the cached + // response is. + CacheAge time.Duration + + // QueryBackend represent which backend served the request. + QueryBackend string + + // DefaultACLPolicy is used to control the ACL interaction when there is no + // defined policy. This can be "allow" which means ACLs are used to + // deny-list, or "deny" which means ACLs are allow-lists. + DefaultACLPolicy string + + // ResultsFilteredByACLs is true when some of the query's results were + // filtered out by enforcing ACLs. It may be false because nothing was + // removed, or because the endpoint does not yet support this flag. + ResultsFilteredByACLs bool +} + +// WriteMeta is used to return meta data about a write +type WriteMeta struct { + // How long did the request take + RequestTime time.Duration +} + +// HttpBasicAuth is used to authenticate http client with HTTP Basic Authentication +type HttpBasicAuth struct { + // Username to use for HTTP Basic Authentication + Username string + + // Password to use for HTTP Basic Authentication + Password string +} + +// Config is used to configure the creation of a client +type Config struct { + // Address is the address of the Consul server + Address string + + // Scheme is the URI scheme for the Consul server + Scheme string + + // Prefix for URIs for when consul is behind an API gateway (reverse + // proxy). The API gateway must strip off the PathPrefix before + // passing the request onto consul. + PathPrefix string + + // Datacenter to use. If not provided, the default agent datacenter is used. + Datacenter string + + // Transport is the Transport to use for the http client. + Transport *http.Transport + + // HttpClient is the client to use. Default will be + // used if not provided. + HttpClient *http.Client + + // HttpAuth is the auth info to use for http access. + HttpAuth *HttpBasicAuth + + // WaitTime limits how long a Watch will block. If not provided, + // the agent default values will be used. + WaitTime time.Duration + + // Token is used to provide a per-request ACL token + // which overrides the agent's default token. + Token string + + // TokenFile is a file containing the current token to use for this client. + // If provided it is read once at startup and never again. + TokenFile string + + // Namespace is the name of the namespace to send along for the request + // when no other Namespace is present in the QueryOptions + Namespace string + + // Partition is the name of the partition to send along for the request + // when no other Partition is present in the QueryOptions + Partition string + + TLSConfig TLSConfig +} + +// TLSConfig is used to generate a TLSClientConfig that's useful for talking to +// Consul using TLS. +type TLSConfig struct { + // Address is the optional address of the Consul server. The port, if any + // will be removed from here and this will be set to the ServerName of the + // resulting config. + Address string + + // CAFile is the optional path to the CA certificate used for Consul + // communication, defaults to the system bundle if not specified. + CAFile string + + // CAPath is the optional path to a directory of CA certificates to use for + // Consul communication, defaults to the system bundle if not specified. + CAPath string + + // CAPem is the optional PEM-encoded CA certificate used for Consul + // communication, defaults to the system bundle if not specified. + CAPem []byte + + // CertFile is the optional path to the certificate for Consul + // communication. If this is set then you need to also set KeyFile. + CertFile string + + // CertPEM is the optional PEM-encoded certificate for Consul + // communication. If this is set then you need to also set KeyPEM. + CertPEM []byte + + // KeyFile is the optional path to the private key for Consul communication. + // If this is set then you need to also set CertFile. + KeyFile string + + // KeyPEM is the optional PEM-encoded private key for Consul communication. + // If this is set then you need to also set CertPEM. + KeyPEM []byte + + // InsecureSkipVerify if set to true will disable TLS host verification. + InsecureSkipVerify bool +} + +// DefaultConfig returns a default configuration for the client. By default this +// will pool and reuse idle connections to Consul. If you have a long-lived +// client object, this is the desired behavior and should make the most efficient +// use of the connections to Consul. If you don't reuse a client object, which +// is not recommended, then you may notice idle connections building up over +// time. To avoid this, use the DefaultNonPooledConfig() instead. +func DefaultConfig() *Config { + return defaultConfig(nil, cleanhttp.DefaultPooledTransport) +} + +// DefaultConfigWithLogger returns a default configuration for the client. It +// is exactly the same as DefaultConfig, but allows for a pre-configured logger +// object to be passed through. +func DefaultConfigWithLogger(logger hclog.Logger) *Config { + return defaultConfig(logger, cleanhttp.DefaultPooledTransport) +} + +// DefaultNonPooledConfig returns a default configuration for the client which +// does not pool connections. This isn't a recommended configuration because it +// will reconnect to Consul on every request, but this is useful to avoid the +// accumulation of idle connections if you make many client objects during the +// lifetime of your application. +func DefaultNonPooledConfig() *Config { + return defaultConfig(nil, cleanhttp.DefaultTransport) +} + +// defaultConfig returns the default configuration for the client, using the +// given function to make the transport. +func defaultConfig(logger hclog.Logger, transportFn func() *http.Transport) *Config { + if logger == nil { + logger = hclog.New(&hclog.LoggerOptions{ + Name: "consul-api", + }) + } + + config := &Config{ + Address: "127.0.0.1:8500", + Scheme: "http", + Transport: transportFn(), + } + + if addr := os.Getenv(HTTPAddrEnvName); addr != "" { + config.Address = addr + } + + if tokenFile := os.Getenv(HTTPTokenFileEnvName); tokenFile != "" { + config.TokenFile = tokenFile + } + + if token := os.Getenv(HTTPTokenEnvName); token != "" { + config.Token = token + } + + if auth := os.Getenv(HTTPAuthEnvName); auth != "" { + var username, password string + if strings.Contains(auth, ":") { + split := strings.SplitN(auth, ":", 2) + username = split[0] + password = split[1] + } else { + username = auth + } + + config.HttpAuth = &HttpBasicAuth{ + Username: username, + Password: password, + } + } + + if ssl := os.Getenv(HTTPSSLEnvName); ssl != "" { + enabled, err := strconv.ParseBool(ssl) if err != nil { - return nil, fmt.Errorf("failed to setup tls config when tried to establish grpc call: %w", err) + logger.Warn(fmt.Sprintf("could not parse %s", HTTPSSLEnvName), "error", err) } - dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + + if enabled { + config.Scheme = "https" + } + } + + if v := os.Getenv(HTTPTLSServerName); v != "" { + config.TLSConfig.Address = v + } + if v := os.Getenv(HTTPCAFile); v != "" { + config.TLSConfig.CAFile = v + } + if v := os.Getenv(HTTPCAPath); v != "" { + config.TLSConfig.CAPath = v + } + if v := os.Getenv(HTTPClientCert); v != "" { + config.TLSConfig.CertFile = v + } + if v := os.Getenv(HTTPClientKey); v != "" { + config.TLSConfig.KeyFile = v + } + if v := os.Getenv(HTTPSSLVerifyEnvName); v != "" { + doVerify, err := strconv.ParseBool(v) + if err != nil { + logger.Warn(fmt.Sprintf("could not parse %s", HTTPSSLVerifyEnvName), "error", err) + } + if !doVerify { + config.TLSConfig.InsecureSkipVerify = true + } + } + + if v := os.Getenv(HTTPNamespaceEnvName); v != "" { + config.Namespace = v + } + + if v := os.Getenv(HTTPPartitionEnvName); v != "" { + config.Partition = v + } + + return config +} + +func (c *Config) GenerateEnv() []string { + env := make([]string, 0, 10) + + env = append(env, + fmt.Sprintf("%s=%s", HTTPAddrEnvName, c.Address), + fmt.Sprintf("%s=%s", HTTPTokenEnvName, c.Token), + fmt.Sprintf("%s=%s", HTTPTokenFileEnvName, c.TokenFile), + fmt.Sprintf("%s=%t", HTTPSSLEnvName, c.Scheme == "https"), + fmt.Sprintf("%s=%s", HTTPCAFile, c.TLSConfig.CAFile), + fmt.Sprintf("%s=%s", HTTPCAPath, c.TLSConfig.CAPath), + fmt.Sprintf("%s=%s", HTTPClientCert, c.TLSConfig.CertFile), + fmt.Sprintf("%s=%s", HTTPClientKey, c.TLSConfig.KeyFile), + fmt.Sprintf("%s=%s", HTTPTLSServerName, c.TLSConfig.Address), + fmt.Sprintf("%s=%t", HTTPSSLVerifyEnvName, !c.TLSConfig.InsecureSkipVerify)) + + if c.HttpAuth != nil { + env = append(env, fmt.Sprintf("%s=%s:%s", HTTPAuthEnvName, c.HttpAuth.Username, c.HttpAuth.Password)) } else { - dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + env = append(env, fmt.Sprintf("%s=", HTTPAuthEnvName)) } - return grpc.Dial(c.Address, dialOpts...) + return env } -func checkCertificates(c *GRPCConfig) error { - if c.GRPCTLS { - certFileEmpty := c.CertFile == "" - keyFileEmpty := c.KeyFile == "" +// Client provides a client to the Consul API +type Client struct { + modifyLock sync.RWMutex + headers http.Header - // both files need to be empty or both files need to be provided - if certFileEmpty != keyFileEmpty { - return fmt.Errorf("you have to provide client certificate file and key file at the same time " + - "if you intend to communicate in TLS/SSL mode") + config api.Config +} + +// Headers gets the current set of headers used for requests. This returns a +// copy; to modify it call AddHeader or SetHeaders. +func (c *Client) Headers() http.Header { + c.modifyLock.RLock() + defer c.modifyLock.RUnlock() + + if c.headers == nil { + return nil + } + + ret := make(http.Header) + for k, v := range c.headers { + for _, val := range v { + ret[k] = append(ret[k], val) } } - return nil + + return ret +} + +// NewClient returns a new client +func NewClient(config *api.Config) (*Client, error) { + // bootstrap the config + defConfig := api.DefaultConfig() + + if config.Address == "" { + config.Address = defConfig.Address + } + + if config.Scheme == "" { + config.Scheme = defConfig.Scheme + } + + if config.Transport == nil { + config.Transport = defConfig.Transport + } + + if config.TLSConfig.Address == "" { + config.TLSConfig.Address = defConfig.TLSConfig.Address + } + + if config.TLSConfig.CAFile == "" { + config.TLSConfig.CAFile = defConfig.TLSConfig.CAFile + } + + if config.TLSConfig.CAPath == "" { + config.TLSConfig.CAPath = defConfig.TLSConfig.CAPath + } + + if config.TLSConfig.CertFile == "" { + config.TLSConfig.CertFile = defConfig.TLSConfig.CertFile + } + + if config.TLSConfig.KeyFile == "" { + config.TLSConfig.KeyFile = defConfig.TLSConfig.KeyFile + } + + if !config.TLSConfig.InsecureSkipVerify { + config.TLSConfig.InsecureSkipVerify = defConfig.TLSConfig.InsecureSkipVerify + } + + if config.HttpClient == nil { + var err error + config.HttpClient, err = NewHttpClient(config.Transport, config.TLSConfig) + if err != nil { + return nil, err + } + } + + if config.Namespace == "" { + config.Namespace = defConfig.Namespace + } + + if config.Partition == "" { + config.Partition = defConfig.Partition + } + + parts := strings.SplitN(config.Address, "://", 2) + if len(parts) == 2 { + switch parts[0] { + case "http": + // Never revert to http if TLS was explicitly requested. + case "https": + config.Scheme = "https" + case "unix": + trans := cleanhttp.DefaultTransport() + trans.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", parts[1]) + } + httpClient, err := NewHttpClient(trans, config.TLSConfig) + if err != nil { + return nil, err + } + config.HttpClient = httpClient + default: + return nil, fmt.Errorf("Unknown protocol scheme: %s", parts[0]) + } + config.Address = parts[1] + + // separate out a reverse proxy prefix, if it is present. + // NOTE: Rewriting this code to use url.Parse() instead of + // strings.SplitN() breaks existing test cases. + switch parts[0] { + case "http", "https": + parts := strings.SplitN(parts[1], "/", 2) + if len(parts) == 2 { + config.Address = parts[0] + config.PathPrefix = "/" + parts[1] + } + } + } + + // If the TokenFile is set, always use that, even if a Token is configured. + // This is because when TokenFile is set it is read into the Token field. + // We want any derived clients to have to re-read the token file. + // The precedence of ACL token should be: + // 1. -token-file cli option + // 2. -token cli option + // 3. CONSUL_HTTP_TOKEN_FILE environment variable + // 4. CONSUL_HTTP_TOKEN environment variable + if config.TokenFile != "" && config.TokenFile != defConfig.TokenFile { + data, err := os.ReadFile(config.TokenFile) + if err != nil { + return nil, fmt.Errorf("Error loading token file %s : %s", config.TokenFile, err) + } + + if token := strings.TrimSpace(string(data)); token != "" { + config.Token = token + } + } else if config.Token != "" && defConfig.Token != config.Token { + // Fall through + } else if defConfig.TokenFile != "" { + data, err := os.ReadFile(defConfig.TokenFile) + if err != nil { + return nil, fmt.Errorf("Error loading token file %s : %s", defConfig.TokenFile, err) + } + + if token := strings.TrimSpace(string(data)); token != "" { + config.Token = token + config.TokenFile = defConfig.TokenFile + } + } else { + config.Token = defConfig.Token + } + return &Client{config: *config, headers: make(http.Header)}, nil +} + +// NewHttpClient returns an http client configured with the given Transport and TLS +// config. +func NewHttpClient(transport *http.Transport, tlsConf api.TLSConfig) (*http.Client, error) { + client := &http.Client{ + Transport: transport, + } + + // TODO (slackpad) - Once we get some run time on the HTTP/2 support we + // should turn it on by default if TLS is enabled. We would basically + // just need to call http2.ConfigureTransport(transport) here. We also + // don't want to introduce another external dependency on + // golang.org/x/net/http2 at this time. For a complete recipe for how + // to enable HTTP/2 support on a transport suitable for the API client + // library see agent/http_test.go:TestHTTPServer_H2. + + if transport.TLSClientConfig == nil { + tlsClientConfig, err := api.SetupTLSConfig(&tlsConf) + + if err != nil { + return nil, err + } + + transport.TLSClientConfig = tlsClientConfig + } + + return client, nil +} + +// request is used to help build up a request +// defined a custom object that includes use-case specific config +type request struct { + config *api.Config + method string + url *url.URL + params url.Values + body io.Reader + header http.Header + Obj interface{} + ctx context.Context +} + +// setQueryOptions is used to annotate the request with +// additional query options +func (r *request) SetQueryOptions(q *QueryOptions) { + if q == nil { + return + } + if q.Namespace != "" { + // For backwards-compatibility with existing tests, + // use the short-hand query param name "ns" + // rather than the alternative long-hand "namespace" + r.params.Set("ns", q.Namespace) + } + if q.Partition != "" { + // For backwards-compatibility with existing tests, + // use the long-hand query param name "partition" + // rather than the alternative short-hand "ap" + r.params.Set("partition", q.Partition) + } + // TODO(peering/v2) handle peer tenancy + if q.Datacenter != "" { + // For backwards-compatibility with existing tests, + // use the short-hand query param name "dc" + // rather than the alternative long-hand "datacenter" + r.params.Set("dc", q.Datacenter) + } + if q.AllowStale { + r.params.Set("stale", "") + } + if q.RequireConsistent { + r.params.Set("consistent", "") + } + if q.WaitIndex != 0 { + r.params.Set("index", strconv.FormatUint(q.WaitIndex, 10)) + } + if q.WaitTime != 0 { + r.params.Set("wait", durToMsec(q.WaitTime)) + } + if q.WaitHash != "" { + r.params.Set("hash", q.WaitHash) + } + if q.Token != "" { + r.header.Set("X-Consul-Token", q.Token) + } + if q.Near != "" { + r.params.Set("near", q.Near) + } + if q.Filter != "" { + r.params.Set("filter", q.Filter) + } + if len(q.NodeMeta) > 0 { + for key, value := range q.NodeMeta { + r.params.Add("node-meta", key+":"+value) + } + } + if q.RelayFactor != 0 { + r.params.Set("relay-factor", strconv.Itoa(int(q.RelayFactor))) + } + if q.LocalOnly { + r.params.Set("local-only", fmt.Sprintf("%t", q.LocalOnly)) + } + if q.Connect { + r.params.Set("connect", "true") + } + if q.UseCache && !q.RequireConsistent { + r.params.Set("cached", "") + + cc := []string{} + if q.MaxAge > 0 { + cc = append(cc, fmt.Sprintf("max-age=%.0f", q.MaxAge.Seconds())) + } + if q.StaleIfError > 0 { + cc = append(cc, fmt.Sprintf("stale-if-error=%.0f", q.StaleIfError.Seconds())) + } + if len(cc) > 0 { + r.header.Set("Cache-Control", strings.Join(cc, ", ")) + } + } + if q.MergeCentralConfig { + r.params.Set("merge-central-config", "") + } + if q.Global { + r.params.Set("global", "") + } + + r.ctx = q.ctx +} + +// durToMsec converts a duration to a millisecond specified string. If the +// user selected a positive value that rounds to 0 ms, then we will use 1 ms +// so they get a short delay, otherwise Consul will translate the 0 ms into +// a huge default delay. +func durToMsec(dur time.Duration) string { + ms := dur / time.Millisecond + if dur > 0 && ms == 0 { + ms = 1 + } + return fmt.Sprintf("%dms", ms) +} + +// toHTTP converts the request to an HTTP request +func (r *request) toHTTP() (*http.Request, error) { + // Encode the query parameters + r.url.RawQuery = r.params.Encode() + + // Check if we should encode the body + if r.body == nil && r.Obj != nil { + b, err := encodeBody(r.Obj) + if err != nil { + return nil, err + } + r.body = b + } + + // Create the HTTP request + req, err := http.NewRequest(r.method, r.url.RequestURI(), r.body) + if err != nil { + return nil, err + } + + // validate that socket communications that do not use the host, detect + // slashes in the host name and replace it with local host. + // this is required since go started validating req.host in 1.20.6 and 1.19.11. + // prior to that they would strip out the slashes for you. They removed that + // behavior and added more strict validation as part of a CVE. + // This issue is being tracked by the Go team: + // https://github.com/golang/go/issues/61431 + // If there is a resolution in this issue, we will remove this code. + // In the time being, this is the accepted workaround. + if strings.HasPrefix(r.url.Host, "/") { + r.url.Host = "localhost" + } + + req.URL.Host = r.url.Host + req.URL.Scheme = r.url.Scheme + req.Host = r.url.Host + req.Header = r.header + + // Content-Type must always be set when a body is present + // See https://github.com/hashicorp/consul/issues/10011 + if req.Body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Setup auth + if r.config.HttpAuth != nil { + req.SetBasicAuth(r.config.HttpAuth.Username, r.config.HttpAuth.Password) + } + if r.ctx != nil { + return req.WithContext(r.ctx), nil + } + + return req, nil +} + +// newRequest is used to create a new request +func (c *Client) NewRequest(method, path string) *request { + r := &request{ + config: &c.config, + method: method, + url: &url.URL{ + Scheme: c.config.Scheme, + Host: c.config.Address, + Path: c.config.PathPrefix + path, + }, + params: make(map[string][]string), + header: c.Headers(), + } + + if c.config.Datacenter != "" { + r.params.Set("dc", c.config.Datacenter) + } + if c.config.Namespace != "" { + r.params.Set("ns", c.config.Namespace) + } + if c.config.Partition != "" { + r.params.Set("partition", c.config.Partition) + } + if c.config.WaitTime != 0 { + r.params.Set("wait", durToMsec(r.config.WaitTime)) + } + if c.config.Token != "" { + r.header.Set("X-Consul-Token", r.config.Token) + } + return r +} + +// doRequest runs a request with our client +func (c *Client) DoRequest(r *request) (time.Duration, *http.Response, error) { + req, err := r.toHTTP() + if err != nil { + return 0, nil, err + } + start := time.Now() + resp, err := c.config.HttpClient.Do(req) + diff := time.Since(start) + return diff, resp, err +} + +// DecodeBody is used to JSON decode a body +func DecodeBody(resp *http.Response, out interface{}) error { + dec := json.NewDecoder(resp.Body) + return dec.Decode(out) +} + +// encodeBody is used to encode a request body +func encodeBody(obj interface{}) (io.Reader, error) { + buf := bytes.NewBuffer(nil) + enc := json.NewEncoder(buf) + if err := enc.Encode(obj); err != nil { + return nil, err + } + return buf, nil +} + +// requireOK is used to wrap doRequest and check for a 200 +func RequireOK(resp *http.Response) error { + return RequireHttpCodes(resp, 200) +} + +// requireHttpCodes checks for the "allowable" http codes for a response +func RequireHttpCodes(resp *http.Response, httpCodes ...int) error { + // if there is an http code that we require, return w no error + for _, httpCode := range httpCodes { + if resp.StatusCode == httpCode { + return nil + } + } + + // if we reached here, then none of the http codes in resp matched any that we expected + // so err out + return generateUnexpectedResponseCodeError(resp) +} + +// closeResponseBody reads resp.Body until EOF, and then closes it. The read +// is necessary to ensure that the http.Client's underlying RoundTripper is able +// to re-use the TCP connection. See godoc on net/http.Client.Do. +func CloseResponseBody(resp *http.Response) error { + _, _ = io.Copy(io.Discard, resp.Body) + return resp.Body.Close() +} + +// generateUnexpectedResponseCodeError consumes the rest of the body, closes +// the body stream and generates an error indicating the status code was +// unexpected. +func generateUnexpectedResponseCodeError(resp *http.Response) error { + var buf bytes.Buffer + io.Copy(&buf, resp.Body) + CloseResponseBody(resp) + + trimmed := strings.TrimSpace(string(buf.Bytes())) + return StatusError{Code: resp.StatusCode, Body: trimmed} } diff --git a/command/resource/client/grpc-client.go b/command/resource/client/grpc-client.go new file mode 100644 index 0000000000..1ba1a481fe --- /dev/null +++ b/command/resource/client/grpc-client.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package client + +import ( + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/hashicorp/consul/proto-public/pbresource" +) + +type GRPCClient struct { + Client pbresource.ResourceServiceClient + Config *GRPCConfig + Conn *grpc.ClientConn +} + +func NewGRPCClient(config *GRPCConfig) (*GRPCClient, error) { + conn, err := dial(config) + if err != nil { + return nil, fmt.Errorf("error dialing grpc: %+v", err) + } + return &GRPCClient{ + Client: pbresource.NewResourceServiceClient(conn), + Config: config, + Conn: conn, + }, nil +} + +func dial(c *GRPCConfig) (*grpc.ClientConn, error) { + err := checkCertificates(c) + if err != nil { + return nil, err + } + var dialOpts []grpc.DialOption + if c.GRPCTLS { + tlsConfig, err := SetupTLSConfig(c) + if err != nil { + return nil, fmt.Errorf("failed to setup tls config when tried to establish grpc call: %w", err) + } + dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + return grpc.Dial(c.Address, dialOpts...) +} + +func checkCertificates(c *GRPCConfig) error { + if c.GRPCTLS { + certFileEmpty := c.CertFile == "" + keyFileEmpty := c.KeyFile == "" + + // both files need to be empty or both files need to be provided + if certFileEmpty != keyFileEmpty { + return fmt.Errorf("you have to provide client certificate file and key file at the same time " + + "if you intend to communicate in TLS/SSL mode") + } + } + return nil +} diff --git a/command/resource/client/client_test.go b/command/resource/client/grpc-client_test.go similarity index 100% rename from command/resource/client/client_test.go rename to command/resource/client/grpc-client_test.go diff --git a/command/resource/client/config.go b/command/resource/client/grpc-config.go similarity index 100% rename from command/resource/client/config.go rename to command/resource/client/grpc-config.go diff --git a/command/resource/client/config_test.go b/command/resource/client/grpc-config_test.go similarity index 100% rename from command/resource/client/config_test.go rename to command/resource/client/grpc-config_test.go diff --git a/command/resource/client/resource-flags.go b/command/resource/client/grpc-resource-flags.go similarity index 100% rename from command/resource/client/resource-flags.go rename to command/resource/client/grpc-resource-flags.go diff --git a/command/resource/client/helper.go b/command/resource/client/helper.go index 7d3245773a..186691c50c 100644 --- a/command/resource/client/helper.go +++ b/command/resource/client/helper.go @@ -5,28 +5,13 @@ package client import ( "crypto/tls" - "encoding/json" - "errors" - "flag" "fmt" - "io" "strconv" "strings" - "unicode" - "unicode/utf8" "github.com/hashicorp/go-rootcerts" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/types/known/anypb" - - "github.com/hashicorp/consul/agent/consul" - "github.com/hashicorp/consul/command/helpers" - "github.com/hashicorp/consul/internal/resourcehcl" - "github.com/hashicorp/consul/proto-public/pbresource" ) -const JSON_INDENT = " " - // tls.Config is used to establish communication in TLS mode func SetupTLSConfig(c *GRPCConfig) (*tls.Config, error) { tlsConfig := &tls.Config{ @@ -106,194 +91,3 @@ func (t *TValue[T]) Merge(onto *T) error { } return nil } - -type OuterResource struct { - ID *ID `json:"id"` - Owner *ID `json:"owner"` - Generation string `json:"generation"` - Version string `json:"version"` - Metadata map[string]any `json:"metadata"` - Data map[string]any `json:"data"` -} - -type Tenancy struct { - Partition string `json:"partition"` - Namespace string `json:"namespace"` -} - -type Type struct { - Group string `json:"group"` - GroupVersion string `json:"groupVersion"` - Kind string `json:"kind"` -} - -type ID struct { - Name string `json:"name"` - Tenancy Tenancy `json:"tenancy"` - Type Type `json:"type"` - UID string `json:"uid"` -} - -func ParseResourceFromFile(filePath string) (*pbresource.Resource, error) { - return ParseResourceInput(filePath, nil) -} - -func ParseResourceInput(filePath string, stdin io.Reader) (*pbresource.Resource, error) { - data, err := helpers.LoadDataSourceNoRaw(filePath, stdin) - - if err != nil { - return nil, fmt.Errorf("Failed to load data: %v", err) - } - var parsedResource *pbresource.Resource - if isHCL([]byte(data)) { - parsedResource, err = resourcehcl.Unmarshal([]byte(data), consul.NewTypeRegistry()) - } else { - parsedResource, err = parseJson(data) - } - if err != nil { - return nil, fmt.Errorf("Failed to decode resource from input: %v", err) - } - - return parsedResource, nil -} - -func ParseInputParams(inputArgs []string, flags *flag.FlagSet) error { - if err := flags.Parse(inputArgs); err != nil { - if !errors.Is(err, flag.ErrHelp) { - return fmt.Errorf("Failed to parse args: %v", err) - } - } - return nil -} - -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") - } - // it has to be resource name after the type - if strings.HasPrefix(args[1], "-") { - return nil, "", fmt.Errorf("Must provide resource name right after type") - } - resourceName = args[1] - - resourceType, e = InferTypeFromResourceType(args[0]) - - return resourceType, resourceName, e -} - -func InferTypeFromResourceType(resourceType string) (*pbresource.Type, error) { - s := strings.Split(resourceType, ".") - switch length := len(s); { - // only kind is provided - case length == 1: - kindToGVKMap := BuildKindToGVKMap() - kind := strings.ToLower(s[0]) - switch len(kindToGVKMap[kind]) { - // no g.v.k is found - case 0: - return nil, fmt.Errorf("The shorthand name does not map to any existing resource type, please check `consul api-resources`") - // only one is found - case 1: - // infer gvk from resource kind - gvkSplit := strings.Split(kindToGVKMap[kind][0], ".") - 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 &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.version.kind format or its shorthand name") - } -} - -func BuildKindToGVKMap() map[string][]string { - // this use the local copy of registration to build map - typeRegistry := consul.NewTypeRegistry() - kindToGVKMap := map[string][]string{} - for _, r := range typeRegistry.Types() { - gvkString := fmt.Sprintf("%s.%s.%s", r.Type.Group, r.Type.GroupVersion, r.Type.Kind) - kindKey := strings.ToLower(r.Type.Kind) - if len(kindToGVKMap[kindKey]) == 0 { - kindToGVKMap[kindKey] = []string{gvkString} - } else { - kindToGVKMap[kindKey] = append(kindToGVKMap[kindKey], gvkString) - } - } - return kindToGVKMap -} - -// this is an inlined variant of hcl.lexMode() -func isHCL(v []byte) bool { - var ( - r rune - w int - offset int - ) - - for { - r, w = utf8.DecodeRune(v[offset:]) - offset += w - if unicode.IsSpace(r) { - continue - } - if r == '{' { - return false - } - break - } - - return true -} - -func parseJson(js string) (*pbresource.Resource, error) { - - parsedResource := new(pbresource.Resource) - - var outerResource OuterResource - - if err := json.Unmarshal([]byte(js), &outerResource); err != nil { - return nil, err - } - - if outerResource.ID == nil { - return nil, fmt.Errorf("\"id\" field need to be provided") - } - - typ := pbresource.Type{ - Kind: outerResource.ID.Type.Kind, - Group: outerResource.ID.Type.Group, - GroupVersion: outerResource.ID.Type.GroupVersion, - } - - reg, ok := consul.NewTypeRegistry().Resolve(&typ) - if !ok { - return nil, fmt.Errorf("invalid type %v", parsedResource) - } - data := reg.Proto.ProtoReflect().New().Interface() - anyProtoMsg, err := anypb.New(data) - if err != nil { - return nil, err - } - - outerResource.Data["@type"] = anyProtoMsg.TypeUrl - - marshal, err := json.Marshal(outerResource) - if err != nil { - return nil, err - } - - if err := protojson.Unmarshal(marshal, parsedResource); err != nil { - return nil, err - } - return parsedResource, nil -} diff --git a/command/resource/client/helper_test.go b/command/resource/client/helper_test.go index e12e185dee..fbedc6552d 100644 --- a/command/resource/client/helper_test.go +++ b/command/resource/client/helper_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestTValue(t *testing.T) { @@ -69,27 +68,3 @@ func TestTValue(t *testing.T) { assert.Equal(t, onto, true) }) } - -func Test_parseJson(t *testing.T) { - tests := []struct { - name string - js string - wantErr bool - }{ - {"valid resource", "{\n \"data\": {\n \"genre\": \"GENRE_METAL\",\n \"name\": \"Korn\"\n },\n \"generation\": \"01HAYWBPV1KMT2KWECJ6CEWDQ0\",\n \"id\": {\n \"name\": \"korn\",\n \"tenancy\": {\n \"namespace\": \"default\",\n \"partition\": \"default\" },\n \"type\": {\n \"group\": \"demo\",\n \"groupVersion\": \"v2\",\n \"kind\": \"Artist\"\n },\n \"uid\": \"01HAYWBPV1KMT2KWECJ4NW88S1\"\n },\n \"metadata\": {\n \"foo\": \"bar\"\n },\n \"version\": \"18\"\n}", false}, - {"invalid resource", "{\n \"data\": {\n \"genre\": \"GENRE_METAL\",\n \"name\": \"Korn\"\n },\n \"id\": {\n \"name\": \"korn\",\n \"tenancy\": {\n \"namespace\": \"default\",\n \"partition\": \"default\" },\n \"type\": \"\"\n },\n \"metadata\": {\n \"foo\": \"bar\"\n }\n}\n", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseJson(tt.js) - if tt.wantErr { - require.Error(t, err) - require.Nil(t, got) - } else { - require.NoError(t, err) - require.NotNil(t, got) - } - - }) - } -} diff --git a/command/resource/delete-grpc/delete.go b/command/resource/delete-grpc/delete.go new file mode 100644 index 0000000000..c924a6bb9d --- /dev/null +++ b/command/resource/delete-grpc/delete.go @@ -0,0 +1,163 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package delete + +import ( + "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 delete 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 + 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{ + Partition: c.resourceFlags.Partition(), + Namespace: c.resourceFlags.Namespace(), + } + } + + // 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 + } + + // delete resource + res := resource.ResourceGRPC{C: resourceClient} + err = res.Delete(resourceType, resourceTenancy, resourceName) + if err != nil { + c.UI.Error(fmt.Sprintf("Error deleting resource %s/%s: %v", resourceType, resourceName, err)) + return 1 + } + + c.UI.Info(fmt.Sprintf("%s.%s.%s/%s deleted", resourceType.Group, resourceType.GroupVersion, resourceType.Kind, resourceName)) + return 0 +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Delete resource information" +const help = ` +Usage: You have two options to delete the resource specified by the given +type, name, partition, namespace and peer and outputs its JSON representation. + +consul resource delete [type] [name] -partition= -namespace= -peer= +consul resource delete -f [resource_file_path] + +But you could only use one of the approaches. + +Example: + +$ consul resource delete catalog.v2beta1.Service card-processor -partition=billing -namespace=payments -peer=eu +$ consul resource delete -f resource.hcl + +In resource.hcl, it could be: +ID { + Type = gvk("catalog.v2beta1.Service") + Name = "card-processor" + Tenancy { + Partition = "billing" + Namespace = "payments" + PeerName = "eu" + } +} +` diff --git a/command/resource/delete-grpc/delete_test.go b/command/resource/delete-grpc/delete_test.go new file mode 100644 index 0000000000..a66cb52324 --- /dev/null +++ b/command/resource/delete-grpc/delete_test.go @@ -0,0 +1,164 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 +package delete + +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 TestResourceDeleteInvalidArgs(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("Must provide resource type argument with either in group.version.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 TestResourceDelete(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", + } + + cases := []struct { + name string + args []string + expectedCode int + createResource bool + }{ + { + name: "delete resource in hcl format", + args: []string{"-f=../testdata/demo.hcl"}, + expectedCode: 0, + createResource: true, + }, + { + name: "delete resource in command line format", + args: []string{"demo.v2.Artist", "korn", "-partition=default", "-namespace=default"}, + expectedCode: 0, + createResource: true, + }, + { + name: "delete resource that doesn't exist in command line format", + args: []string{"demo.v2.Artist", "korn", "-partition=default", "-namespace=default"}, + expectedCode: 0, + createResource: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + cliArgs := append(tc.args, defaultCmdArgs...) + if tc.createResource { + createResource(t, availablePort) + } + code := c.Run(cliArgs) + require.Empty(t, ui.ErrorWriter.String()) + require.Equal(t, tc.expectedCode, code) + require.Contains(t, ui.OutputWriter.String(), "deleted") + }) + } +} diff --git a/command/resource/delete/delete.go b/command/resource/delete/delete.go index d79e8c6105..bbe2d04139 100644 --- a/command/resource/delete/delete.go +++ b/command/resource/delete/delete.go @@ -10,7 +10,9 @@ import ( "github.com/mitchellh/cli" + "github.com/hashicorp/consul/api" "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" ) @@ -22,71 +24,82 @@ func New(ui cli.Ui) *cmd { } type cmd struct { - UI cli.Ui - flags *flag.FlagSet - grpcFlags *client.GRPCFlags - resourceFlags *client.ResourceFlags - help string + UI cli.Ui + flags *flag.FlagSet + http *flags.HTTPFlags + 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) + c.http = &flags.HTTPFlags{} + c.flags.StringVar(&c.filePath, "f", "", "File path with resource definition") + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.MultiTenancyFlags()) + // TODO(peering/v2) add back ability to query peers + // flags.Merge(c.flags, c.http.AddPeerName()) + c.help = flags.Usage(help, c.flags) } func (c *cmd) Run(args []string) int { - var resourceType *pbresource.Type - var resourceTenancy *pbresource.Tenancy + var gvk *resource.GVK var resourceName string + var opts *client.QueryOptions 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 delete command: %v", err)) - return 1 } - // collect resource type, name and tenancy if c.flags.Lookup("f").Value.String() != "" { - if c.filePath == "" { + if c.filePath != "" { + 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 + } + + gvk = &resource.GVK{ + Group: parsedResource.Id.Type.GetGroup(), + Version: parsedResource.Id.Type.GetGroupVersion(), + Kind: parsedResource.Id.Type.GetKind(), + } + resourceName = parsedResource.Id.GetName() + opts = &client.QueryOptions{ + Namespace: parsedResource.Id.Tenancy.GetNamespace(), + Partition: parsedResource.Id.Tenancy.GetPartition(), + Token: c.http.Token(), + } + } else { c.UI.Error(fmt.Sprintf("Please provide an input file with resource definition")) return 1 } - parsedResource, err := client.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 - resourceName = parsedResource.Id.Name } else { var err error - resourceType, resourceName, err = client.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 } inputArgs := args[2:] - err = client.ParseInputParams(inputArgs, c.flags) + err = resource.ParseInputParams(inputArgs, c.flags) if err != nil { c.UI.Error(fmt.Sprintf("Error parsing input arguments: %v", err)) return 1 @@ -95,33 +108,30 @@ func (c *cmd) Run(args []string) int { c.UI.Error("Incorrect argument format: File argument is not needed when resource information is provided with the command") return 1 } - resourceTenancy = &pbresource.Tenancy{ - Partition: c.resourceFlags.Partition(), - Namespace: c.resourceFlags.Namespace(), + opts = &client.QueryOptions{ + Namespace: c.http.Namespace(), + Partition: c.http.Partition(), + Token: c.http.Token(), } } - // 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) + config := api.DefaultConfig() + + c.http.MergeOntoConfig(config) + resourceClient, err := client.NewClient(config) if err != nil { c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) return 1 } - // delete resource - err = resourceClient.Delete(resourceType, resourceTenancy, resourceName) - if err != nil { - c.UI.Error(fmt.Sprintf("Error deleting resource %s/%s: %v", resourceType, resourceName, err)) + res := resource.Resource{C: resourceClient} + + if err := res.Delete(gvk, resourceName, opts); err != nil { + c.UI.Error(fmt.Sprintf("Error deleting resource %s.%s.%s/%s: %v", gvk.Group, gvk.Version, gvk.Kind, resourceName, err)) return 1 } - c.UI.Info(fmt.Sprintf("%s.%s.%s/%s deleted", resourceType.Group, resourceType.GroupVersion, resourceType.Kind, resourceName)) + c.UI.Info(fmt.Sprintf("%s.%s.%s/%s deleted", gvk.Group, gvk.Version, gvk.Kind, resourceName)) return 0 } @@ -136,7 +146,7 @@ func (c *cmd) Help() string { const synopsis = "Delete resource information" const help = ` Usage: You have two options to delete the resource specified by the given -type, name, partition, namespace and outputs its JSON representation. +type, name, partition and namespace and outputs its JSON representation. consul resource delete [type] [name] -partition= -namespace= consul resource delete -f [resource_file_path] @@ -150,11 +160,11 @@ $ consul resource delete -f resource.hcl In resource.hcl, it could be: ID { - Type = gvk("catalog.v2beta1.Service") - Name = "card-processor" - Tenancy { - Partition = "billing" - Namespace = "payments" - } + Type = gvk("catalog.v2beta1.Service") + Name = "card-processor" + Tenancy { + Namespace = "payments" + Partition = "billing" + } } ` diff --git a/command/resource/delete/delete_test.go b/command/resource/delete/delete_test.go index 30874f8d14..8e12aa486d 100644 --- a/command/resource/delete/delete_test.go +++ b/command/resource/delete/delete_test.go @@ -4,7 +4,6 @@ package delete import ( "errors" - "fmt" "testing" "github.com/mitchellh/cli" @@ -12,7 +11,6 @@ import ( "github.com/hashicorp/consul/agent" "github.com/hashicorp/consul/command/resource/apply" - "github.com/hashicorp/consul/sdk/freeport" "github.com/hashicorp/consul/testrpc" ) @@ -86,12 +84,12 @@ func TestResourceDeleteInvalidArgs(t *testing.T) { } } -func createResource(t *testing.T, port int) { +func createResource(t *testing.T, a *agent.TestAgent) { applyUi := cli.NewMockUi() applyCmd := apply.New(applyUi) args := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", port), + "-http-addr=" + a.HTTPAddr(), "-token=root", } @@ -109,18 +107,14 @@ func TestResourceDelete(t *testing.T) { t.Parallel() - availablePort := freeport.GetOne(t) - a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() testrpc.WaitForTestAgent(t, a.RPC, "dc1") - t.Cleanup(func() { - a.Shutdown() - }) defaultCmdArgs := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-http-addr=" + a.HTTPAddr(), "-token=root", } - cases := []struct { name string args []string @@ -153,7 +147,7 @@ func TestResourceDelete(t *testing.T) { c := New(ui) cliArgs := append(tc.args, defaultCmdArgs...) if tc.createResource { - createResource(t, availablePort) + createResource(t, a) } code := c.Run(cliArgs) require.Empty(t, ui.ErrorWriter.String()) diff --git a/command/resource/helper.go b/command/resource/helper.go new file mode 100644 index 0000000000..69a06d9cc1 --- /dev/null +++ b/command/resource/helper.go @@ -0,0 +1,322 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package resource + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "strings" + "unicode" + "unicode/utf8" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/hashicorp/consul/agent/consul" + "github.com/hashicorp/consul/command/helpers" + "github.com/hashicorp/consul/command/resource/client" + "github.com/hashicorp/consul/internal/resourcehcl" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +const JSON_INDENT = " " + +type OuterResource struct { + ID *ID `json:"id"` + Owner *ID `json:"owner"` + Generation string `json:"generation"` + Version string `json:"version"` + Metadata map[string]any `json:"metadata"` + Data map[string]any `json:"data"` +} + +type Tenancy struct { + Partition string `json:"partition"` + Namespace string `json:"namespace"` +} + +// TODO(peering/v2) handle v2 peering in the resource cli + +type Type struct { + Group string `json:"group"` + GroupVersion string `json:"groupVersion"` + Kind string `json:"kind"` +} + +type ID struct { + Name string `json:"name"` + Tenancy Tenancy `json:"tenancy"` + Type Type `json:"type"` + UID string `json:"uid"` +} + +func parseJson(js string) (*pbresource.Resource, error) { + + parsedResource := new(pbresource.Resource) + + var outerResource OuterResource + + if err := json.Unmarshal([]byte(js), &outerResource); err != nil { + return nil, err + } + + if outerResource.ID == nil { + return nil, fmt.Errorf("\"id\" field need to be provided") + } + + typ := pbresource.Type{ + Kind: outerResource.ID.Type.Kind, + Group: outerResource.ID.Type.Group, + GroupVersion: outerResource.ID.Type.GroupVersion, + } + + reg, ok := consul.NewTypeRegistry().Resolve(&typ) + if !ok { + return nil, fmt.Errorf("invalid type %v", parsedResource) + } + data := reg.Proto.ProtoReflect().New().Interface() + anyProtoMsg, err := anypb.New(data) + if err != nil { + return nil, err + } + + outerResource.Data["@type"] = anyProtoMsg.TypeUrl + + marshal, err := json.Marshal(outerResource) + if err != nil { + return nil, err + } + + if err := protojson.Unmarshal(marshal, parsedResource); err != nil { + return nil, err + } + return parsedResource, nil +} + +func ParseResourceFromFile(filePath string) (*pbresource.Resource, error) { + return ParseResourceInput(filePath, nil) +} + +// this is an inlined variant of hcl.lexMode() +func isHCL(v []byte) bool { + var ( + r rune + w int + offset int + ) + + for { + r, w = utf8.DecodeRune(v[offset:]) + offset += w + if unicode.IsSpace(r) { + continue + } + if r == '{' { + return false + } + break + } + + return true +} + +func ParseResourceInput(filePath string, stdin io.Reader) (*pbresource.Resource, error) { + data, err := helpers.LoadDataSourceNoRaw(filePath, stdin) + + if err != nil { + return nil, fmt.Errorf("Failed to load data: %v", err) + } + var parsedResource *pbresource.Resource + if isHCL([]byte(data)) { + parsedResource, err = resourcehcl.Unmarshal([]byte(data), consul.NewTypeRegistry()) + } else { + parsedResource, err = parseJson(data) + } + if err != nil { + return nil, fmt.Errorf("Failed to decode resource from input: %v", err) + } + + return parsedResource, nil +} + +func ParseInputParams(inputArgs []string, flags *flag.FlagSet) error { + if err := flags.Parse(inputArgs); err != nil { + if !errors.Is(err, flag.ErrHelp) { + return fmt.Errorf("Failed to parse args: %v", err) + } + } + return nil +} + +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") + } + // it has to be resource name after the type + if strings.HasPrefix(args[1], "-") { + return nil, "", fmt.Errorf("Must provide resource name right after type") + } + resourceName = args[1] + + resourceType, e = InferTypeFromResourceType(args[0]) + + return resourceType, resourceName, e +} + +type Resource struct { + C *client.Client +} + +type GVK struct { + Group string + Version string + Kind string +} + +type WriteRequest struct { + Metadata map[string]string `json:"metadata"` + Data map[string]any `json:"data"` + Owner *pbresource.ID `json:"owner"` +} + +type ListResponse struct { + Resources []map[string]interface{} `json:"resources"` +} + +func (gvk *GVK) String() string { + return fmt.Sprintf("%s.%s.%s", gvk.Group, gvk.Version, gvk.Kind) +} + +func (resource *Resource) Read(gvk *GVK, resourceName string, q *client.QueryOptions) (map[string]interface{}, error) { + r := resource.C.NewRequest("GET", strings.ToLower(fmt.Sprintf("/api/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, resourceName))) + r.SetQueryOptions(q) + _, resp, err := resource.C.DoRequest(r) + if err != nil { + return nil, err + } + defer client.CloseResponseBody(resp) + if err := client.RequireOK(resp); err != nil { + return nil, err + } + + var out map[string]interface{} + if err := client.DecodeBody(resp, &out); err != nil { + return nil, err + } + + return out, nil +} + +func (resource *Resource) Delete(gvk *GVK, resourceName string, q *client.QueryOptions) error { + r := resource.C.NewRequest("DELETE", strings.ToLower(fmt.Sprintf("/api/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, resourceName))) + r.SetQueryOptions(q) + _, resp, err := resource.C.DoRequest(r) + if err != nil { + return err + } + defer client.CloseResponseBody(resp) + if err := client.RequireHttpCodes(resp, http.StatusNoContent); err != nil { + return err + } + return nil +} + +func (resource *Resource) Apply(gvk *GVK, resourceName string, q *client.QueryOptions, payload *WriteRequest) (*map[string]interface{}, error) { + url := strings.ToLower(fmt.Sprintf("/api/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, resourceName)) + + r := resource.C.NewRequest("PUT", url) + r.SetQueryOptions(q) + r.Obj = payload + _, resp, err := resource.C.DoRequest(r) + if err != nil { + return nil, err + } + defer client.CloseResponseBody(resp) + if err := client.RequireOK(resp); err != nil { + return nil, err + } + + var out map[string]interface{} + + if err := client.DecodeBody(resp, &out); err != nil { + return nil, err + } + + return &out, nil +} + +func (resource *Resource) List(gvk *GVK, q *client.QueryOptions) (*ListResponse, error) { + r := resource.C.NewRequest("GET", strings.ToLower(fmt.Sprintf("/api/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind))) + r.SetQueryOptions(q) + _, resp, err := resource.C.DoRequest(r) + if err != nil { + return nil, err + } + defer client.CloseResponseBody(resp) + if err := client.RequireOK(resp); err != nil { + return nil, err + } + + var out *ListResponse + if err := client.DecodeBody(resp, &out); err != nil { + return nil, err + } + + return out, nil +} + +func InferTypeFromResourceType(resourceType string) (*pbresource.Type, error) { + s := strings.Split(resourceType, ".") + switch length := len(s); { + // only kind is provided + case length == 1: + kindToGVKMap := BuildKindToGVKMap() + kind := strings.ToLower(s[0]) + switch len(kindToGVKMap[kind]) { + // no g.v.k is found + case 0: + return nil, fmt.Errorf("The shorthand name does not map to any existing resource type, please check `consul api-resources`") + // only one is found + case 1: + // infer gvk from resource kind + gvkSplit := strings.Split(kindToGVKMap[kind][0], ".") + 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 &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.version.kind format or its shorthand name") + } +} + +func BuildKindToGVKMap() map[string][]string { + // this use the local copy of registration to build map + typeRegistry := consul.NewTypeRegistry() + kindToGVKMap := map[string][]string{} + for _, r := range typeRegistry.Types() { + gvkString := fmt.Sprintf("%s.%s.%s", r.Type.Group, r.Type.GroupVersion, r.Type.Kind) + kindKey := strings.ToLower(r.Type.Kind) + if len(kindToGVKMap[kindKey]) == 0 { + kindToGVKMap[kindKey] = []string{gvkString} + } else { + kindToGVKMap[kindKey] = append(kindToGVKMap[kindKey], gvkString) + } + } + return kindToGVKMap +} diff --git a/command/resource/helper_test.go b/command/resource/helper_test.go new file mode 100644 index 0000000000..1b3d6cbb29 --- /dev/null +++ b/command/resource/helper_test.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package resource + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_parseJson(t *testing.T) { + tests := []struct { + name string + js string + wantErr bool + }{ + {"valid resource", "{\n \"data\": {\n \"genre\": \"GENRE_METAL\",\n \"name\": \"Korn\"\n },\n \"generation\": \"01HAYWBPV1KMT2KWECJ6CEWDQ0\",\n \"id\": {\n \"name\": \"korn\",\n \"tenancy\": {\n \"namespace\": \"default\",\n \"partition\": \"default\"\n },\n \"type\": {\n \"group\": \"demo\",\n \"groupVersion\": \"v2\",\n \"kind\": \"Artist\"\n },\n \"uid\": \"01HAYWBPV1KMT2KWECJ4NW88S1\"\n },\n \"metadata\": {\n \"foo\": \"bar\"\n },\n \"version\": \"18\"\n}", false}, + {"invalid resource", "{\n \"data\": {\n \"genre\": \"GENRE_METAL\",\n \"name\": \"Korn\"\n },\n \"id\": {\n \"name\": \"korn\",\n \"tenancy\": {\n \"namespace\": \"default\",\n \"partition\": \"default\"\n },\n \"type\": \"\"\n },\n \"metadata\": {\n \"foo\": \"bar\"\n }\n}\n", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseJson(tt.js) + if tt.wantErr { + require.Error(t, err) + require.Nil(t, got) + } else { + require.NoError(t, err) + require.NotNil(t, got) + } + + }) + } +} diff --git a/command/resource/list-grpc/list.go b/command/resource/list-grpc/list.go new file mode 100644 index 0000000000..eea7621b9e --- /dev/null +++ b/command/resource/list-grpc/list.go @@ -0,0 +1,192 @@ +// 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{ + Partition: c.resourceFlags.Partition(), + Namespace: c.resourceFlags.Namespace(), + } + } + + // 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 { + Partition = "default" + Namespace = "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..0b660f6874 --- /dev/null +++ b/command/resource/list-grpc/list_test.go @@ -0,0 +1,192 @@ +// 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", + "-partition=default", + "-namespace=default", + }, + }, + { + name: "sample output with name prefix", + output: "\"name\": \"korn\"", + extraArgs: []string{ + "demo.v2.Artist", + "-p=korn", + "-partition=default", + "-namespace=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", + "-partition=default", + "-namespace=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", + "-partition=default", + "-namespace=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", + "-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/list/list.go b/command/resource/list/list.go index c29d462fca..86e5fd875f 100644 --- a/command/resource/list/list.go +++ b/command/resource/list/list.go @@ -12,9 +12,10 @@ import ( "github.com/mitchellh/cli" + "github.com/hashicorp/consul/api" "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 { @@ -24,78 +25,76 @@ func New(ui cli.Ui) *cmd { } type cmd struct { - UI cli.Ui - flags *flag.FlagSet - grpcFlags *client.GRPCFlags - resourceFlags *client.ResourceFlags - help string + UI cli.Ui + flags *flag.FlagSet + http *flags.HTTPFlags + 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) + c.http = &flags.HTTPFlags{} + c.flags.StringVar(&c.filePath, "f", "", "File path with resource definition") + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.MultiTenancyFlags()) + // TODO(peering/v2) add back ability to query peers + // flags.Merge(c.flags, c.http.AddPeerName()) + c.help = flags.Usage(help, c.flags) } func (c *cmd) Run(args []string) int { - var resourceType *pbresource.Type - var resourceTenancy *pbresource.Tenancy + var gvk *resource.GVK + var opts *client.QueryOptions 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 == "" { + if c.filePath != "" { + 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 + } + + gvk = &resource.GVK{ + Group: parsedResource.Id.Type.GetGroup(), + Version: parsedResource.Id.Type.GetGroupVersion(), + Kind: parsedResource.Id.Type.GetKind(), + } + opts = &client.QueryOptions{ + Namespace: parsedResource.Id.Tenancy.GetNamespace(), + Partition: parsedResource.Id.Tenancy.GetPartition(), + Token: c.http.Token(), + RequireConsistent: !c.http.Stale(), + } + } else { c.UI.Error(fmt.Sprintf("Please provide an input file with resource definition")) return 1 } - parsedResource, err := client.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 = client.InferTypeFromResourceType(args[0]) + // extract resource type + gvk, err = getResourceType(c.flags.Args()) if err != nil { - c.UI.Error(fmt.Sprintf("Incorrect argument format: %s", err)) + c.UI.Error(fmt.Sprintf("Incorrect argument format: %v", err)) return 1 } - // skip resource type to parse remaining args inputArgs := c.flags.Args()[1:] - err = client.ParseInputParams(inputArgs, c.flags) + err = resource.ParseInputParams(inputArgs, c.flags) if err != nil { c.UI.Error(fmt.Sprintf("Error parsing input arguments: %v", err)) return 1 @@ -104,34 +103,33 @@ func (c *cmd) Run(args []string) int { c.UI.Error("Incorrect argument format: File argument is not needed when resource information is provided with the command") return 1 } - resourceTenancy = &pbresource.Tenancy{ - Partition: c.resourceFlags.Partition(), - Namespace: c.resourceFlags.Namespace(), + + opts = &client.QueryOptions{ + Namespace: c.http.Namespace(), + Partition: c.http.Partition(), + Token: c.http.Token(), + RequireConsistent: !c.http.Stale(), } } - // 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) + config := api.DefaultConfig() + + c.http.MergeOntoConfig(config) + resourceClient, err := client.NewClient(config) if err != nil { c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) return 1 } - // list resource - entry, err := resourceClient.List(resourceType, resourceTenancy, c.prefix, c.resourceFlags.Stale()) + res := resource.Resource{C: resourceClient} + + entry, err := res.List(gvk, opts) if err != nil { - c.UI.Error(fmt.Sprintf("Error listing resource %s/%s: %v", resourceType, c.prefix, err)) + c.UI.Error(fmt.Sprintf("Error reading resources for type %s: %v", gvk, err)) return 1 } - // display response - b, err := json.MarshalIndent(entry, "", client.JSON_INDENT) + b, err := json.MarshalIndent(entry, "", " ") if err != nil { c.UI.Error("Failed to encode output data") return 1 @@ -141,17 +139,26 @@ func (c *cmd) Run(args []string) int { return 0 } -func validateArgs(args []string) error { - if args == nil { - return fmt.Errorf("Must include resource type or flag arguments") - } +func getResourceType(args []string) (gvk *resource.GVK, e error) { if len(args) < 1 { - return fmt.Errorf("Must include resource type argument") + return nil, fmt.Errorf("Must include resource type argument") } + // it should not have resource name if len(args) > 1 && !strings.HasPrefix(args[1], "-") { - return fmt.Errorf("Must include flag arguments after resource type") + return nil, fmt.Errorf("Must include flag arguments after resource type") } - return nil + + s := strings.Split(args[0], ".") + if len(s) < 3 { + return nil, fmt.Errorf("Must include resource type argument in group.version.kind format") + } + gvk = &resource.GVK{ + Group: s[0], + Version: s[1], + Kind: s[2], + } + + return } func (c *cmd) Synopsis() string { @@ -162,28 +169,29 @@ func (c *cmd) Help() string { return flags.Usage(c.help, nil) } -const synopsis = "Lists all resources by name prefix" +const synopsis = "Reads all resources by type" const help = ` Usage: consul resource list [type] -partition= -namespace= or consul resource list -f [path/to/file.hcl] -Lists all the resources specified by the type under the given partition, namespace +Lists all the resources specified by the type under the given partition and namespace and outputs in JSON format. Example: -$ consul resource list catalog.v2beta1.Service -p=card -partition=billing -namespace=payments +$ consul resource list catalog.v2beta1.Service card-processor -partition=billing -namespace=payments -$ consul resource list -f=demo.hcl -p=card +$ consul resource list -f=demo.hcl Sample demo.hcl: ID { Type = gvk("group.version.kind") + Name = "resource-name" Tenancy { - Partition = "default" - Namespace = "default" + Namespace = "default" + Partition = "default" } } ` diff --git a/command/resource/list/list_test.go b/command/resource/list/list_test.go index a3df6c7524..92afe4a6dc 100644 --- a/command/resource/list/list_test.go +++ b/command/resource/list/list_test.go @@ -5,13 +5,11 @@ 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" @@ -25,19 +23,16 @@ func TestResourceListCommand(t *testing.T) { } t.Parallel() - availablePort := freeport.GetOne(t) - a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() 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), + "-http-addr=" + a.HTTPAddr(), "-token=root", }) require.Equal(t, 0, code) @@ -53,19 +48,9 @@ func TestResourceListCommand(t *testing.T) { name: "sample output", output: "\"name\": \"korn\"", extraArgs: []string{ - "demo.v2.Artist", - "-partition=default", + "demo.v2.artist", "-namespace=default", - }, - }, - { - name: "sample output with name prefix", - output: "\"name\": \"korn\"", - extraArgs: []string{ - "demo.v2.Artist", - "-p=korn", "-partition=default", - "-namespace=default", }, }, { @@ -83,7 +68,7 @@ func TestResourceListCommand(t *testing.T) { c := New(ui) args := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-http-addr=" + a.HTTPAddr(), "-token=root", } @@ -100,12 +85,9 @@ func TestResourceListCommand(t *testing.T) { func TestResourceListInvalidArgs(t *testing.T) { t.Parallel() - availablePort := freeport.GetOne(t) - a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() testrpc.WaitForTestAgent(t, a.RPC, "dc1") - t.Cleanup(func() { - a.Shutdown() - }) type tc struct { args []string @@ -117,7 +99,7 @@ func TestResourceListInvalidArgs(t *testing.T) { "nil args": { args: nil, expectedCode: 1, - expectedErr: errors.New("Incorrect argument format: Must include resource type or flag arguments"), + expectedErr: errors.New("Incorrect argument format: Must include resource type argument"), }, "minimum args required": { args: []string{}, @@ -147,10 +129,10 @@ func TestResourceListInvalidArgs(t *testing.T) { }, "file argument with resource type": { args: []string{ - "demo.v2.Artist", - "-partition=default", + "demo.v2.artist", "-namespace=default", - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-partition=default", + "-http-addr=" + a.HTTPAddr(), "-token=root", "-f=demo.hcl", }, @@ -160,21 +142,21 @@ func TestResourceListInvalidArgs(t *testing.T) { "resource type invalid": { args: []string{ "test", - "-partition=default", "-namespace=default", + "-partition=default", }, expectedCode: 1, - expectedErr: errors.New("Incorrect argument format: The shorthand name does not map to any existing resource type"), + expectedErr: errors.New("Must include resource type argument in group.version.kind format"), }, "resource name is provided": { args: []string{ - "demo.v2.Artist", + "demo.v2.artist", "test", "-namespace=default", "-partition=default", }, expectedCode: 1, - expectedErr: errors.New("Incorrect argument format: Must include flag arguments after resource type"), + expectedErr: errors.New("Must include flag arguments after resource type"), }, } diff --git a/command/resource/read-grpc/read.go b/command/resource/read-grpc/read.go new file mode 100644 index 0000000000..c681074ba7 --- /dev/null +++ b/command/resource/read-grpc/read.go @@ -0,0 +1,171 @@ +// 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{ + Partition: c.resourceFlags.Partition(), + Namespace: c.resourceFlags.Namespace(), + } + } + + // 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 { + Partition = "billing" + Namespace = "payments" + 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..b38388eb9e --- /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.version.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"}, + expectedCode: 0, + errMsg: "", + }, + { + name: "read resource that doesn't exist", + args: []string{"demo.v2.Artist", "fake-korn", "-partition=default", "-namespace=default"}, + 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 e00c332987..14c38c45f4 100644 --- a/command/resource/read/read.go +++ b/command/resource/read/read.go @@ -11,7 +11,9 @@ import ( "github.com/mitchellh/cli" + "github.com/hashicorp/consul/api" "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" ) @@ -23,71 +25,83 @@ func New(ui cli.Ui) *cmd { } type cmd struct { - UI cli.Ui - flags *flag.FlagSet - grpcFlags *client.GRPCFlags - resourceFlags *client.ResourceFlags - help string + UI cli.Ui + flags *flag.FlagSet + http *flags.HTTPFlags + 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) + c.http = &flags.HTTPFlags{} + c.flags.StringVar(&c.filePath, "f", "", "File path with resource definition") + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.MultiTenancyFlags()) + // TODO(peering/v2) add back ability to query peers + // flags.Merge(c.flags, c.http.AddPeerName()) + c.help = flags.Usage(help, c.flags) } func (c *cmd) Run(args []string) int { - var resourceType *pbresource.Type - var resourceTenancy *pbresource.Tenancy + var gvk *resource.GVK var resourceName string + var opts *client.QueryOptions 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 == "" { + if c.filePath != "" { + 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 + } + + gvk = &resource.GVK{ + Group: parsedResource.Id.Type.GetGroup(), + Version: parsedResource.Id.Type.GetGroupVersion(), + Kind: parsedResource.Id.Type.GetKind(), + } + resourceName = parsedResource.Id.GetName() + opts = &client.QueryOptions{ + Namespace: parsedResource.Id.Tenancy.GetNamespace(), + Partition: parsedResource.Id.Tenancy.GetPartition(), + Token: c.http.Token(), + RequireConsistent: !c.http.Stale(), + } + } else { c.UI.Error(fmt.Sprintf("Please provide an input file with resource definition")) return 1 } - parsedResource, err := client.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 = client.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 } inputArgs := args[2:] - err = client.ParseInputParams(inputArgs, c.flags) + err = resource.ParseInputParams(inputArgs, c.flags) if err != nil { c.UI.Error(fmt.Sprintf("Error parsing input arguments: %v", err)) return 1 @@ -96,34 +110,32 @@ func (c *cmd) Run(args []string) int { c.UI.Error("Incorrect argument format: File argument is not needed when resource information is provided with the command") return 1 } - resourceTenancy = &pbresource.Tenancy{ - Partition: c.resourceFlags.Partition(), - Namespace: c.resourceFlags.Namespace(), + opts = &client.QueryOptions{ + Namespace: c.http.Namespace(), + Partition: c.http.Partition(), + Token: c.http.Token(), + RequireConsistent: !c.http.Stale(), } } - // initialize client - config, err := client.LoadGRPCConfig(nil) + config := api.DefaultConfig() + + c.http.MergeOntoConfig(config) + resourceClient, err := client.NewClient(config) 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)) + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) return 1 } - // read resource - entry, err := resourceClient.Read(resourceType, resourceTenancy, resourceName, c.resourceFlags.Stale()) + res := resource.Resource{C: resourceClient} + + entry, err := res.Read(gvk, resourceName, opts) if err != nil { - c.UI.Error(fmt.Sprintf("Error reading resource %s/%s: %v", resourceType, resourceName, err)) + c.UI.Error(fmt.Sprintf("Error reading resource %s/%s: %v", gvk, resourceName, err)) return 1 } - // display response - b, err := json.MarshalIndent(entry, "", client.JSON_INDENT) + b, err := json.MarshalIndent(entry, "", " ") if err != nil { c.UI.Error("Failed to encode output data") return 1 @@ -144,7 +156,7 @@ func (c *cmd) Help() string { 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 outputs its JSON representation. +type, name, partition and namespace and outputs its JSON representation. consul resource read [type] [name] -partition= -namespace= consul resource read -f [resource_file_path] @@ -158,11 +170,11 @@ $ consul resource read -f resource.hcl In resource.hcl, it could be: ID { - Type = gvk("catalog.v2beta1.Service") - Name = "card-processor" - Tenancy { - Partition = "billing" - Namespace = "payments" - } + Type = gvk("catalog.v2beta1.Service") + Name = "card-processor" + Tenancy { + Namespace = "payments" + Partition = "billing" + } } ` diff --git a/command/resource/read/read_test.go b/command/resource/read/read_test.go index 2df02baa39..1a4de5703e 100644 --- a/command/resource/read/read_test.go +++ b/command/resource/read/read_test.go @@ -4,7 +4,6 @@ package read import ( "errors" - "fmt" "testing" "github.com/mitchellh/cli" @@ -12,7 +11,6 @@ import ( "github.com/hashicorp/consul/agent" "github.com/hashicorp/consul/command/resource/apply" - "github.com/hashicorp/consul/sdk/freeport" "github.com/hashicorp/consul/testrpc" ) @@ -86,12 +84,12 @@ func TestResourceReadInvalidArgs(t *testing.T) { } } -func createResource(t *testing.T, port int) { +func createResource(t *testing.T, a *agent.TestAgent) { applyUi := cli.NewMockUi() applyCmd := apply.New(applyUi) args := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", port), + "-http-addr=" + a.HTTPAddr(), "-token=root", } @@ -109,19 +107,16 @@ func TestResourceRead(t *testing.T) { t.Parallel() - availablePort := freeport.GetOne(t) - a := agent.NewTestAgent(t, fmt.Sprintf("ports { grpc = %d }", availablePort)) + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() testrpc.WaitForTestAgent(t, a.RPC, "dc1") - t.Cleanup(func() { - a.Shutdown() - }) defaultCmdArgs := []string{ - fmt.Sprintf("-grpc-addr=127.0.0.1:%d", availablePort), + "-http-addr=" + a.HTTPAddr(), "-token=root", } - createResource(t, availablePort) + createResource(t, a) cases := []struct { name string args []string @@ -144,7 +139,7 @@ func TestResourceRead(t *testing.T) { name: "read resource that doesn't exist", args: []string{"demo.v2.Artist", "fake-korn", "-partition=default", "-namespace=default"}, expectedCode: 1, - errMsg: "error reading resource: rpc error: code = NotFound desc = resource not found\n", + errMsg: "Error reading resource demo.v2.Artist/fake-korn: Unexpected response code: 404 (rpc error: code = NotFound desc = resource not found)\n", }, } @@ -154,7 +149,7 @@ func TestResourceRead(t *testing.T) { c := New(ui) cliArgs := append(tc.args, defaultCmdArgs...) code := c.Run(cliArgs) - require.Contains(t, ui.ErrorWriter.String(), tc.errMsg) + require.Equal(t, tc.errMsg, ui.ErrorWriter.String()) require.Equal(t, tc.expectedCode, code) }) } diff --git a/command/resource/resource-grpc.go b/command/resource/resource-grpc.go new file mode 100644 index 0000000000..94d7314597 --- /dev/null +++ b/command/resource/resource-grpc.go @@ -0,0 +1,123 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package resource + +import ( + "context" + "fmt" + + "google.golang.org/grpc/metadata" + + "github.com/hashicorp/consul/command/resource/client" + "github.com/hashicorp/consul/proto-public/pbresource" +) + +const ( + HeaderConsulToken = "x-consul-token" +) + +type ResourceGRPC struct { + C *client.GRPCClient +} + +func (resource *ResourceGRPC) Apply(parsedResource *pbresource.Resource) (*pbresource.Resource, error) { + token, err := resource.C.Config.GetToken() + if err != nil { + return nil, err + } + ctx := context.Background() + if token != "" { + ctx = metadata.AppendToOutgoingContext(context.Background(), HeaderConsulToken, token) + } + + defer resource.C.Conn.Close() + writeRsp, err := resource.C.Client.Write(ctx, &pbresource.WriteRequest{Resource: parsedResource}) + if err != nil { + return nil, fmt.Errorf("error writing resource: %+v", err) + } + + 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 +} + +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 +} + +func (resource *ResourceGRPC) Delete(resourceType *pbresource.Type, resourceTenancy *pbresource.Tenancy, resourceName string) error { + token, err := resource.C.Config.GetToken() + if err != nil { + return err + } + ctx := context.Background() + if token != "" { + ctx = metadata.AppendToOutgoingContext(context.Background(), HeaderConsulToken, token) + } + + defer resource.C.Conn.Close() + _, err = resource.C.Client.Delete(ctx, &pbresource.DeleteRequest{ + Id: &pbresource.ID{ + Type: resourceType, + Tenancy: resourceTenancy, + Name: resourceName, + }, + }) + + if err != nil { + return fmt.Errorf("error deleting resource: %+v", err) + } + + return nil +} diff --git a/command/resource/resource.go b/command/resource/resource.go index 381351cc59..e7a74e498b 100644 --- a/command/resource/resource.go +++ b/command/resource/resource.go @@ -47,10 +47,6 @@ List resources by type: $ consul resource list [type] -partition= -namespace= -Delete a resource: - -$ consul resource delete [type] [name] -partition= -namespace= -consistent= -json - Run consul resource -h diff --git a/test/integration/consul-container/test/resource/grpc_forwarding_test.go b/test/integration/consul-container/test/resource/grpc_forwarding_test.go deleted file mode 100644 index faa75c6bfd..0000000000 --- a/test/integration/consul-container/test/resource/grpc_forwarding_test.go +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -package resource - -import ( - "context" - "fmt" - "io" - "testing" - - "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - - libcluster "github.com/hashicorp/consul/test/integration/consul-container/libs/cluster" - libtopology "github.com/hashicorp/consul/test/integration/consul-container/libs/topology" -) - -const ( - RESOURCE_FILE_PATH_ON_HOST = "../../../../../command/resource/testdata/demo.hcl" - CLIENT_CERT_ON_HOST = "../../../../client_certs/client.crt" - CLIENT_KEY_ON_HOST = "../../../../client_certs/client.key" - ROOT_CA_ON_HOST = "../../../../client_certs/rootca.crt" - RESOURCE_FILE_PATH_ON_CONTAINER = "/consul/data/demo.hcl" - CLIENT_CERT_ON_CONTAINER = "/consul/data/client.crt" - CLIENT_KEY_ON_CONTAINER = "/consul/data/client.key" - ROOT_CA_ON_CONTAINER = "/consul/data/rootca.crt" -) - -func TestClientForwardToServer(t *testing.T) { - type operation struct { - action func(*testing.T, libcluster.Agent, string, bool) (int, string) - includeToken bool - expectedCode int - expectedMsg string - } - type testCase struct { - description string - operation operation - aclEnabled bool - tlsEnabled bool - verifyIncoming bool - } - - testCases := []testCase{ - { - description: "The apply request should be forwarded to consul server agent", - operation: operation{ - action: applyResource, - includeToken: false, - expectedCode: 0, - expectedMsg: "demo.v2.Artist 'korn' created.", - }, - aclEnabled: false, - tlsEnabled: false, - verifyIncoming: false, - }, - { - description: "The apply request should be denied if missing token when ACL is enabled", - operation: operation{ - action: applyResource, - includeToken: false, - expectedCode: 1, - expectedMsg: "failed getting authorizer: ACL not found", - }, - aclEnabled: true, - }, - { - description: "The apply request should be allowed if providing token when ACL is enabled", - operation: operation{ - action: applyResource, - includeToken: true, - expectedCode: 0, - expectedMsg: "demo.v2.Artist 'korn' created.", - }, - aclEnabled: true, - tlsEnabled: false, - verifyIncoming: false, - }, - { - description: "The apply request should be forwarded to consul server agent when server is in TLS mode", - operation: operation{ - action: applyResource, - includeToken: false, - expectedCode: 0, - expectedMsg: "demo.v2.Artist 'korn' created.", - }, - aclEnabled: false, - tlsEnabled: true, - verifyIncoming: false, - }, - { - description: "The apply request should be forwarded to consul server agent when server and client are in TLS mode", - operation: operation{ - action: applyResource, - includeToken: false, - expectedCode: 0, - expectedMsg: "demo.v2.Artist 'korn' created.", - }, - aclEnabled: false, - tlsEnabled: true, - verifyIncoming: true, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.description, func(t *testing.T) { - t.Parallel() - - var clientAgent libcluster.Agent - cluster, clientAgent := setupClusterAndClient(t, tc.aclEnabled, tc.tlsEnabled, tc.verifyIncoming) - defer terminate(t, cluster) - - // perform actions and validate returned messages - token := "" - if tc.operation.includeToken { - token = cluster.TokenBootstrap - } - code, res := tc.operation.action(t, clientAgent, token, tc.verifyIncoming) - require.Equal(t, tc.operation.expectedCode, code) - require.Contains(t, res, tc.operation.expectedMsg) - }) - } -} - -func applyResource(t *testing.T, clientAgent libcluster.Agent, token string, verifyIncoming bool) (int, string) { - c := clientAgent.GetConsulContainer() - copyFilesToContainer(t, c, verifyIncoming) - args := []string{"/bin/consul", "resource", "apply", fmt.Sprintf("-f=%s", RESOURCE_FILE_PATH_ON_CONTAINER)} - if token != "" { - args = append(args, fmt.Sprintf("-token=%s", token)) - } - if verifyIncoming { - args = append( - args, - "-grpc-tls=true", - "-grpc-addr=127.0.0.1:8503", - fmt.Sprintf("-client-cert=%s", CLIENT_CERT_ON_CONTAINER), - fmt.Sprintf("-client-key=%s", CLIENT_KEY_ON_CONTAINER), - fmt.Sprintf("-ca-file=%s", ROOT_CA_ON_CONTAINER), - ) - } - code, reader, err := c.Exec(context.Background(), args) - require.NoError(t, err) - buf, err := io.ReadAll(reader) - require.NoError(t, err) - return code, string(buf) -} - -func copyFilesToContainer(t *testing.T, c testcontainers.Container, verifyIncoming bool) { - err := c.CopyFileToContainer(context.Background(), RESOURCE_FILE_PATH_ON_HOST, RESOURCE_FILE_PATH_ON_CONTAINER, 700) - require.NoError(t, err) - if verifyIncoming { - err = c.CopyFileToContainer(context.Background(), CLIENT_CERT_ON_HOST, CLIENT_CERT_ON_CONTAINER, 700) - require.NoError(t, err) - err = c.CopyFileToContainer(context.Background(), CLIENT_KEY_ON_HOST, CLIENT_KEY_ON_CONTAINER, 700) - require.NoError(t, err) - err = c.CopyFileToContainer(context.Background(), ROOT_CA_ON_HOST, ROOT_CA_ON_CONTAINER, 700) - require.NoError(t, err) - } -} - -func setupClusterAndClient(t *testing.T, aclEnabled bool, tlsEnabled bool, verifyIncoming bool) (*libcluster.Cluster, libcluster.Agent) { - clusterConfig := &libtopology.ClusterConfig{ - NumServers: 1, - NumClients: 1, - LogConsumer: &libtopology.TestLogConsumer{}, - BuildOpts: &libcluster.BuildOptions{ - Datacenter: "dc1", - InjectAutoEncryption: tlsEnabled, - UseGRPCWithTLS: tlsEnabled, - ACLEnabled: aclEnabled, - }, - ApplyDefaultProxySettings: false, - } - if verifyIncoming { - clusterConfig.Cmd = "-hcl=tls { defaults { verify_incoming = true } }" - } - cluster, _, _ := libtopology.NewCluster(t, clusterConfig) - - return cluster, cluster.Clients()[0] -} - -func terminate(t *testing.T, cluster *libcluster.Cluster) { - err := cluster.Terminate() - require.NoError(t, err) -}