diff --git a/api/resource.go b/api/resource.go new file mode 100644 index 0000000000..97baf35934 --- /dev/null +++ b/api/resource.go @@ -0,0 +1,44 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package api + +import ( + "fmt" + "strings" +) + +type Resource struct { + c *Client +} + +type GVK struct { + Group string + Version string + Kind string +} + +// Config returns a handle to the Config endpoints +func (c *Client) Resource() *Resource { + return &Resource{c} +} + +func (resource *Resource) Read(gvk *GVK, resourceName string, q *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 closeResponseBody(resp) + if err := requireOK(resp); err != nil { + return nil, err + } + + var out map[string]interface{} + if err := decodeBody(resp, &out); err != nil { + return nil, err + } + + return out, nil +} diff --git a/command/flags/http.go b/command/flags/http.go index 16157adc8a..009c26629c 100644 --- a/command/flags/http.go +++ b/command/flags/http.go @@ -29,6 +29,7 @@ type HTTPFlags struct { // multi-tenancy flags namespace StringValue partition StringValue + peer StringValue } func (f *HTTPFlags) ClientFlags() *flag.FlagSet { @@ -109,6 +110,10 @@ func (f *HTTPFlags) Partition() string { return f.partition.String() } +func (f *HTTPFlags) PeerName() string { + return f.peer.String() +} + func (f *HTTPFlags) Stale() bool { if f.stale.v == nil { return false @@ -174,3 +179,9 @@ func (f *HTTPFlags) AddPartitionFlag(fs *flag.FlagSet) { "from the request's ACL token, or will default to the `default` admin partition. "+ "Admin Partitions are a Consul Enterprise feature.") } + +func (f *HTTPFlags) AddPeerName() *flag.FlagSet { + fs := flag.NewFlagSet("", flag.ContinueOnError) + fs.Var(&f.peer, "peer", "Specifies the name of peer to query. By default, it is `local`.") + return fs +} diff --git a/command/registry.go b/command/registry.go index f01e3a1080..55bfb1ad59 100644 --- a/command/registry.go +++ b/command/registry.go @@ -108,6 +108,8 @@ import ( peerlist "github.com/hashicorp/consul/command/peering/list" peerread "github.com/hashicorp/consul/command/peering/read" "github.com/hashicorp/consul/command/reload" + "github.com/hashicorp/consul/command/resource" + resourceread "github.com/hashicorp/consul/command/resource/read" "github.com/hashicorp/consul/command/rtt" "github.com/hashicorp/consul/command/services" svcsderegister "github.com/hashicorp/consul/command/services/deregister" @@ -238,6 +240,8 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { entry{"peering list", func(ui cli.Ui) (cli.Command, error) { return peerlist.New(ui), nil }}, 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 read", func(ui cli.Ui) (cli.Command, error) { return resourceread.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/read/read.go b/command/resource/read/read.go new file mode 100644 index 0000000000..022f64099f --- /dev/null +++ b/command/resource/read/read.go @@ -0,0 +1,199 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package read + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "strings" + + "github.com/mitchellh/cli" + + "github.com/hashicorp/consul/agent/consul" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/consul/command/helpers" + "github.com/hashicorp/consul/internal/resourcehcl" +) + +func New(ui cli.Ui) *cmd { + c := &cmd{UI: ui} + c.init() + return c +} + +type cmd struct { + UI cli.Ui + flags *flag.FlagSet + http *flags.HTTPFlags + help string + + filePath string +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + 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()) + flags.Merge(c.flags, c.http.AddPeerName()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + var gvk *api.GVK + var resourceName string + var opts *api.QueryOptions + + if len(args) == 0 { + c.UI.Error("Please provide required arguments") + return 1 + } + + 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 + } + } + + if c.flags.Lookup("f").Value.String() != "" { + if c.filePath != "" { + data, err := helpers.LoadDataSourceNoRaw(c.filePath, nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to load data: %v", err)) + return 1 + } + parsedResource, err := resourcehcl.Unmarshal([]byte(data), consul.NewTypeRegistry()) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to decode resource from input file: %v", err)) + return 1 + } + + gvk = &api.GVK{ + Group: parsedResource.Id.Type.GetGroup(), + Version: parsedResource.Id.Type.GetGroupVersion(), + Kind: parsedResource.Id.Type.GetKind(), + } + resourceName = parsedResource.Id.GetName() + opts = &api.QueryOptions{ + Namespace: parsedResource.Id.Tenancy.GetNamespace(), + Partition: parsedResource.Id.Tenancy.GetPartition(), + Peer: parsedResource.Id.Tenancy.GetPeerName(), + Token: c.http.Token(), + RequireConsistent: !c.http.Stale(), + } + } else { + c.UI.Error(fmt.Sprintf("Please provide an input file with resource definition")) + return 1 + } + } else { + if len(args) < 2 { + c.UI.Error("Must specify two arguments: resource type and resource name") + return 1 + } + var err error + gvk, resourceName, err = getTypeAndResourceName(args) + if err != nil { + c.UI.Error(fmt.Sprintf("Your argument format is incorrect: %s", err)) + return 1 + } + + inputArgs := args[2:] + if err := c.flags.Parse(inputArgs); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + c.UI.Error(fmt.Sprintf("Failed to parse args: %v", err)) + return 1 + } + if c.filePath != "" { + c.UI.Error("You need to provide all information in the HCL file if provide its file path") + return 1 + } + opts = &api.QueryOptions{ + Namespace: c.http.Namespace(), + Partition: c.http.Partition(), + Peer: c.http.PeerName(), + Token: c.http.Token(), + RequireConsistent: !c.http.Stale(), + } + } + + client, err := c.http.APIClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) + return 1 + } + + entry, err := client.Resource().Read(gvk, resourceName, opts) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading resource %s/%s: %v", gvk, resourceName, err)) + return 1 + } + + b, err := json.MarshalIndent(entry, "", " ") + if err != nil { + c.UI.Error("Failed to encode output data") + return 1 + } + + c.UI.Info(string(b)) + return 0 +} + +func getTypeAndResourceName(args []string) (gvk *api.GVK, resourceName string, e error) { + if strings.HasPrefix(args[1], "-") { + return nil, "", fmt.Errorf("Must provide resource name right after type") + } + + s := strings.Split(args[0], ".") + gvk = &api.GVK{ + Group: s[0], + Version: s[1], + Kind: s[2], + } + + resourceName = args[1] + return +} + +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.v1alpha1.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.v1alpha1.Service") + Name = "card-processor" + Tenancy { + Namespace = "payments" + Partition = "billing" + PeerName = "eu" + } +} +` diff --git a/command/resource/read/read_test.go b/command/resource/read/read_test.go new file mode 100644 index 0000000000..69e9654779 --- /dev/null +++ b/command/resource/read/read_test.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 +package read + +import ( + "testing" + + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestResourceReadInvalidArgs(t *testing.T) { + t.Parallel() + + type tc struct { + args []string + expectedCode int + expectedErrMsg string + } + + cases := map[string]tc{ + "nil args": { + args: nil, + expectedCode: 1, + expectedErrMsg: "Please provide required arguments", + }, + "empty args": { + args: []string{}, + expectedCode: 1, + expectedErrMsg: "Please provide required arguments", + }, + "missing file path": { + args: []string{"-f"}, + expectedCode: 1, + expectedErrMsg: "Please input file path", + }, + "provide type and name": { + args: []string{"a.b.c"}, + expectedCode: 1, + expectedErrMsg: "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, + expectedErrMsg: "You need to provide all information in the HCL file if provide its file path", + }, + "provide type and name with -f and other flags": { + args: []string{"a.b.c", "name", "-f", "test.hcl", "-namespace", "default"}, + expectedCode: 1, + expectedErrMsg: "You need to provide all information in the HCL file if provide its file path", + }, + "does not provide resource name after type": { + args: []string{"a.b.c", "-namespace", "default"}, + expectedCode: 1, + expectedErrMsg: "Must provide resource name right after type", + }, + } + + for desc, tc := range cases { + t.Run(desc, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + require.Equal(t, tc.expectedCode, c.Run(tc.args)) + require.NotEmpty(t, ui.ErrorWriter.String()) + }) + } +} + +func TestResourceRead(t *testing.T) { + // TODO: add read test after apply checked in + //if testing.Short() { + // t.Skip("too slow for testing.Short") + //} + // + //t.Parallel() + // + //a := agent.NewTestAgent(t, ``) + //defer a.Shutdown() + //client := a.Client() + // + //ui := cli.NewMockUi() + //c := New(ui) + + //_, _, err := client.Resource().Apply() + //require.NoError(t, err) + // + //args := []string{} + // + //code := c.Run(args) + //require.Equal(t, 0, code) +} diff --git a/command/resource/resource.go b/command/resource/resource.go new file mode 100644 index 0000000000..cd9b8313db --- /dev/null +++ b/command/resource/resource.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package resource + +import ( + "github.com/mitchellh/cli" + + "github.com/hashicorp/consul/command/flags" +) + +func New() *cmd { + return &cmd{} +} + +type cmd struct{} + +func (c *cmd) Run(args []string) int { + return cli.RunResultHelp +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(help, nil) +} + +const synopsis = "Interact with Consul's resources" +const help = ` +Usage: consul resource [options] + +This command has subcommands for interacting with Consul's resources. +Here are some simple examples, and more detailed examples are available +in the subcommands or the documentation. + +Read a resource: + +$ consul resource read [type] [name] -partition= -namespace= -peer= -consistent= -json + +Run + +consul resource -h + +for help on that subcommand. +` diff --git a/internal/resource/http/http.go b/internal/resource/http/http.go index 2a5cfce1fb..8692a3a033 100644 --- a/internal/resource/http/http.go +++ b/internal/resource/http/http.go @@ -176,10 +176,18 @@ func (h *resourceHandler) handleDelete(w http.ResponseWriter, r *http.Request, c func parseParams(r *http.Request) (tenancy *pbresource.Tenancy, params map[string]string) { query := r.URL.Query() + namespace := query.Get("namespace") + if namespace == "" { + namespace = query.Get("ns") + } + peer := query.Get("peer") + if peer == "" { + peer = query.Get("peer_name") + } tenancy = &pbresource.Tenancy{ Partition: query.Get("partition"), - PeerName: query.Get("peer_name"), - Namespace: query.Get("namespace"), + PeerName: peer, + Namespace: namespace, } resourceName := path.Base(r.URL.Path) @@ -191,6 +199,9 @@ func parseParams(r *http.Request) (tenancy *pbresource.Tenancy, params map[strin params["resourceName"] = resourceName params["version"] = query.Get("version") params["namePrefix"] = query.Get("name_prefix") + // coming from command line + params["consistent"] = query.Get("RequireConsistent") + // coming from http client if _, ok := query["consistent"]; ok { params["consistent"] = "true" }