From a2d81acadcc9b524074b09ef91ef87cc8d3ca451 Mon Sep 17 00:00:00 2001 From: Poonam Jadhav Date: Fri, 18 Aug 2023 10:05:37 -0400 Subject: [PATCH] feat: implement apply command --- api/resource.go | 35 ++++++ command/registry.go | 2 + command/resource/apply/apply.go | 162 +++++++++++++++++++++++++++ command/resource/apply/apply_test.go | 82 ++++++++++++++ command/resource/resource.go | 4 + command/resource/testdata/demo.hcl | 18 +++ 6 files changed, 303 insertions(+) create mode 100644 command/resource/apply/apply.go create mode 100644 command/resource/apply/apply_test.go create mode 100644 command/resource/testdata/demo.hcl diff --git a/api/resource.go b/api/resource.go index c80fc3e44434..c42c62fe99a0 100644 --- a/api/resource.go +++ b/api/resource.go @@ -5,6 +5,9 @@ package api import ( "fmt" + "strings" + + "github.com/hashicorp/consul/proto-public/pbresource" ) type Resource struct { @@ -17,6 +20,12 @@ type GVK struct { Kind string } +type WriteRequest struct { + Metadata map[string]string `json:"metadata"` + Data map[string]string `json:"data"` + Owner *pbresource.ID `json:"owner"` +} + // Config returns a handle to the Config endpoints func (c *Client) Resource() *Resource { return &Resource{c} @@ -41,3 +50,29 @@ func (resource *Resource) Read(gvk *GVK, resourceName string, q *QueryOptions) ( return out, nil } + +func (resource *Resource) Apply(gvk *GVK, resourceName string, q *QueryOptions, payload *WriteRequest) (map[string]interface{}, *WriteMeta, 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 + rtt, resp, err := resource.c.doRequest(r) + if err != nil { + return nil, nil, err + } + defer closeResponseBody(resp) + if err := requireOK(resp); err != nil { + return nil, nil, err + } + + wm := &WriteMeta{} + wm.RequestTime = rtt + + var out map[string]interface{} + if err := decodeBody(resp, &out); err != nil { + return nil, nil, err + } + + return out, wm, nil +} diff --git a/command/registry.go b/command/registry.go index 55bfb1ad5984..dba80e11814d 100644 --- a/command/registry.go +++ b/command/registry.go @@ -109,6 +109,7 @@ import ( peerread "github.com/hashicorp/consul/command/peering/read" "github.com/hashicorp/consul/command/reload" "github.com/hashicorp/consul/command/resource" + resourceapply "github.com/hashicorp/consul/command/resource/apply" resourceread "github.com/hashicorp/consul/command/resource/read" "github.com/hashicorp/consul/command/rtt" "github.com/hashicorp/consul/command/services" @@ -242,6 +243,7 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { 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{"resource apply", func(ui cli.Ui) (cli.Command, error) { return resourceapply.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 new file mode 100644 index 000000000000..62b9435ae544 --- /dev/null +++ b/command/resource/apply/apply.go @@ -0,0 +1,162 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package apply + +import ( + "encoding/json" + "flag" + "fmt" + + "github.com/mitchellh/cli" + "google.golang.org/protobuf/encoding/protojson" + + "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" + "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 + 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.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + c.help = flags.Usage(help, c.flags) +} + +func makeWriteRequest(parsedResource *pbresource.Resource) (payload *api.WriteRequest, error error) { + data, err := protojson.Marshal(parsedResource.Data) + if err != nil { + return nil, fmt.Errorf("unrecognized hcl format: %s", err) + } + + var d map[string]string + err = json.Unmarshal(data, &d) + if err != nil { + return nil, fmt.Errorf("unrecognized hcl format: %s", err) + } + delete(d, "@type") + + return &api.WriteRequest{ + Data: d, + Metadata: parsedResource.GetMetadata(), + Owner: parsedResource.GetOwner(), + }, nil +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + if err == flag.ErrHelp { + return 0 + } + 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) + 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)) + return 1 + } + } + + client, err := c.http.APIClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) + return 1 + } + + opts := &api.QueryOptions{ + Namespace: parsedResource.Id.Tenancy.GetNamespace(), + Partition: parsedResource.Id.Tenancy.GetPartition(), + Peer: parsedResource.Id.Tenancy.GetPeerName(), + Token: c.http.Token(), + } + + gvk := &api.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 parsing hcl input: %v", err)) + return 1 + } + + entry, _, err := client.Resource().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.", gvk.Group, gvk.Version, gvk.Kind, parsedResource.Id.GetName())) + c.UI.Info(string(b)) + return 0 +} + +func parseResource(data string) (resource *pbresource.Resource, e error) { + // parse the data + raw := []byte(data) + resource, err := resourcehcl.Unmarshal(raw, consul.NewTypeRegistry()) + if err != nil { + return nil, fmt.Errorf("Failed to decode resource from input file: %v", err) + } + + return resource, nil +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Writes/updates resource information" +const help = ` +Usage: consul resource apply -f= + +Writes and/or updates a resource from the definition in the hcl file provided as argument + +Example: + +$ consul resource apply -f=demo.hcl +` diff --git a/command/resource/apply/apply_test.go b/command/resource/apply/apply_test.go new file mode 100644 index 000000000000..1a935ed1dfea --- /dev/null +++ b/command/resource/apply/apply_test.go @@ -0,0 +1,82 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package apply + +import ( + "errors" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/testrpc" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestResourceApplyCommand(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") + + cases := []struct { + name string + output string + }{ + { + name: "sample output", + output: "demo.v2.Artist 'korn' created.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + args := []string{ + "-f=../testdata/demo.hcl", + "-http-addr=" + a.HTTPAddr(), + "-token=root", + } + + code := c.Run(args) + require.Equal(t, 0, code) + require.Empty(t, ui.ErrorWriter.String()) + require.Contains(t, ui.OutputWriter.String(), tc.output) + }) + } +} + +func TestResourceApplyInvalidArgs(t *testing.T) { + t.Parallel() + + type tc struct { + args []string + expectedCode int + expectedErr error + } + + cases := map[string]tc{ + "missing file path": { + args: []string{"-f"}, + expectedCode: 1, + expectedErr: errors.New("Failed to parse args: flag needs an argument: -f"), + }, + } + + 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 cd9b8313dbae..f36266e52357 100644 --- a/command/resource/resource.go +++ b/command/resource/resource.go @@ -39,6 +39,10 @@ Read a resource: $ consul resource read [type] [name] -partition= -namespace= -peer= -consistent= -json +Write/update a resource: + +$ consul resource apply -f= + Run consul resource -h diff --git a/command/resource/testdata/demo.hcl b/command/resource/testdata/demo.hcl new file mode 100644 index 000000000000..473cab608913 --- /dev/null +++ b/command/resource/testdata/demo.hcl @@ -0,0 +1,18 @@ +ID { + Type = gvk("demo.v2.Artist") + Name = "korn" + Tenancy { + Namespace = "default" + Partition = "default" + PeerName = "local" + } +} + +Data { + Name = "Korn" + Genre = "GENRE_METAL" +} + +Metadata = { + "foo" = "bar" +}