diff --git a/api/resource.go b/api/resource.go index db5938c6f4..27066039eb 100644 --- a/api/resource.go +++ b/api/resource.go @@ -33,6 +33,11 @@ type WriteResponse struct { ID *pbresource.ID `json:"id"` Version string `json:"version"` Generation string `json:"generation"` + Status map[string]any `json:"status"` +} + +type ListResponse struct { + Resources []WriteResponse `json:"resources"` } // Config returns a handle to the Config endpoints @@ -84,3 +89,23 @@ func (resource *Resource) Apply(gvk *GVK, resourceName string, q *QueryOptions, } return out, wm, nil } + +func (resource *Resource) List(gvk *GVK, q *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 closeResponseBody(resp) + if err := requireOK(resp); err != nil { + return nil, err + } + + var out *ListResponse + if err := decodeBody(resp, &out); err != nil { + return nil, err + } + + return out, nil +} diff --git a/command/registry.go b/command/registry.go index dba80e1181..047458e6e0 100644 --- a/command/registry.go +++ b/command/registry.go @@ -110,6 +110,7 @@ import ( "github.com/hashicorp/consul/command/reload" "github.com/hashicorp/consul/command/resource" resourceapply "github.com/hashicorp/consul/command/resource/apply" + resourcelist "github.com/hashicorp/consul/command/resource/list" resourceread "github.com/hashicorp/consul/command/resource/read" "github.com/hashicorp/consul/command/rtt" "github.com/hashicorp/consul/command/services" @@ -244,6 +245,7 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { 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{"resource apply", func(ui cli.Ui) (cli.Command, error) { return resourceapply.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/apply.go b/command/resource/apply/apply.go index 91d79e445b..cba9ef30d2 100644 --- a/command/resource/apply/apply.go +++ b/command/resource/apply/apply.go @@ -15,7 +15,7 @@ import ( "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/command/resource" "github.com/hashicorp/consul/internal/resourcehcl" "github.com/hashicorp/consul/proto-public/pbresource" ) @@ -69,27 +69,21 @@ func makeWriteRequest(parsedResource *pbresource.Resource) (payload *api.WriteRe func (c *cmd) Run(args []string) int { if err := c.flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return 0 + 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 parse args: %v", err)) - return 1 } var parsedResource *pbresource.Resource if c.filePath != "" { - data, err := helpers.LoadDataSourceNoRaw(c.filePath, nil) + data, err := resource.ParseResourceFromFile(c.filePath) if err != nil { - c.UI.Error(fmt.Sprintf("Failed to load data: %v", err)) - return 1 - } - - parsedResource, err = parseResource(data) - if err != nil { - c.UI.Error(fmt.Sprintf("Your argument format is incorrect: %s", err)) + c.UI.Error(fmt.Sprintf("Failed to decode resource from input file: %v", err)) return 1 } + parsedResource = data } else { c.UI.Error("Flag -f is required") return 1 diff --git a/command/resource/helper.go b/command/resource/helper.go new file mode 100644 index 0000000000..0bdeb5415d --- /dev/null +++ b/command/resource/helper.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package resource + +import ( + "errors" + "flag" + "fmt" + + "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" +) + +func ParseResourceFromFile(filePath string) (*pbresource.Resource, error) { + data, err := helpers.LoadDataSourceNoRaw(filePath, nil) + if err != nil { + return nil, fmt.Errorf("Failed to load data: %v", err) + } + parsedResource, err := resourcehcl.Unmarshal([]byte(data), consul.NewTypeRegistry()) + if err != nil { + return nil, fmt.Errorf("Failed to decode resource from input file: %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 +} diff --git a/command/resource/list/list.go b/command/resource/list/list.go new file mode 100644 index 0000000000..3c81f135cb --- /dev/null +++ b/command/resource/list/list.go @@ -0,0 +1,193 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package list + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "strings" + + "github.com/mitchellh/cli" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/consul/command/resource" +) + +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 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 != "" { + 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 = &api.GVK{ + Group: parsedResource.Id.Type.GetGroup(), + Version: parsedResource.Id.Type.GetGroupVersion(), + Kind: parsedResource.Id.Type.GetKind(), + } + 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 { + var err error + // extract resource type + gvk, err = getResourceType(c.flags.Args()) + if err != nil { + c.UI.Error(fmt.Sprintf("Your argument format is incorrect: %v", 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.Warn(fmt.Sprintf("File argument is ignored when resource definition is provided with the command")) + } + + 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().List(gvk, opts) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading resources for type %s: %v", gvk, 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 getResourceType(args []string) (gvk *api.GVK, e error) { + if len(args) < 1 { + return nil, fmt.Errorf("Must include resource type argument") + } + + s := strings.Split(args[0], ".") + if len(s) < 3 { + return nil, fmt.Errorf("Must include resource type argument in group.verion.kind format") + } + gvk = &api.GVK{ + Group: s[0], + Version: s[1], + Kind: s[2], + } + + return +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Reads all resources by type" +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.v1alpha1.Service card-processor -partition=billing -namespace=payments -peer=eu + +$ consul resource list -f=demo.hcl + +Sample demo.hcl: + +ID { + Type = gvk("group.version.kind") + Name = "resource-name" + Tenancy { + Namespace = "default" + Partition = "default" + PeerName = "local" + } + } +` diff --git a/command/resource/list/list_test.go b/command/resource/list/list_test.go new file mode 100644 index 0000000000..377d00e724 --- /dev/null +++ b/command/resource/list/list_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package list + +import ( + "errors" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/testrpc" + "github.com/mitchellh/cli" + + "github.com/stretchr/testify/require" + + apply "github.com/hashicorp/consul/command/resource/apply" +) + +func TestResourceListCommand(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + testrpc.WaitForTestAgent(t, a.RPC, "dc1") + + applyCli := cli.NewMockUi() + + applyCmd := apply.New(applyCli) + code := applyCmd.Run([]string{ + "-f=../testdata/demo.hcl", + "-http-addr=" + a.HTTPAddr(), + "-token=root", + }) + require.Equal(t, 0, code) + require.Empty(t, applyCli.ErrorWriter.String()) + require.Contains(t, applyCli.OutputWriter.String(), "demo.v2.Artist 'korn' created.") + + cases := []struct { + name string + output string + extraArgs []string + }{ + { + name: "sample output", + output: "\"name\": \"korn\"", + extraArgs: []string{ + "demo.v2.artist", + "-namespace=default", + "-peer=local", + "-partition=default", + }, + }, + { + name: "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{ + "-http-addr=" + a.HTTPAddr(), + "-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() + + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + testrpc.WaitForTestAgent(t, a.RPC, "dc1") + + type tc struct { + args []string + expectedCode int + expectedErr error + } + + cases := map[string]tc{ + "nil args": { + args: nil, + expectedCode: 1, + expectedErr: errors.New("Please provide required arguments"), + }, + "minimum args required": { + args: []string{}, + expectedCode: 1, + expectedErr: errors.New("Please provide required arguments"), + }, + "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 is ignored": { + args: []string{ + "demo.v2.artist", + "-namespace=default", + "-peer=local", + "-partition=default", + "-http-addr=" + a.HTTPAddr(), + "-token=root", + "-f=demo.hcl", + }, + expectedCode: 0, + expectedErr: errors.New("File argument is ignored when resource definition is provided with the command"), + }, + "resource type invalid": { + args: []string{ + "test", + "-namespace=default", + "-peer=local", + "-partition=default", + }, + expectedCode: 1, + expectedErr: errors.New("Must include resource type argument in group.verion.kind format"), + }, + } + + for desc, tc := range cases { + t.Run(desc, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + code := c.Run(tc.args) + + require.Equal(t, tc.expectedCode, code) + require.Contains(t, ui.ErrorWriter.String(), tc.expectedErr.Error()) + }) + } +} diff --git a/command/resource/resource.go b/command/resource/resource.go index f36266e523..709b03e4e4 100644 --- a/command/resource/resource.go +++ b/command/resource/resource.go @@ -43,6 +43,10 @@ Write/update a resource: $ consul resource apply -f= +List resources by type: + +$ consul resource list [type] -partition= -namespace= -peer= + Run consul resource -h diff --git a/command/resource/testdata/invalid_type.hcl b/command/resource/testdata/invalid_type.hcl new file mode 100644 index 0000000000..b24e5699cf --- /dev/null +++ b/command/resource/testdata/invalid_type.hcl @@ -0,0 +1,8 @@ +D { + Type = gvk("demo.v2.Artist") + Tenancy { + Namespace = "default" + Partition = "default" + PeerName = "local" + } +}