diff --git a/command/resource/apply/apply.go b/command/resource/apply/apply.go index 9f0b8e4581..df3f136c50 100644 --- a/command/resource/apply/apply.go +++ b/command/resource/apply/apply.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "fmt" + "io" "github.com/mitchellh/cli" "google.golang.org/protobuf/encoding/protojson" @@ -32,6 +33,8 @@ type cmd struct { help string filePath string + + testStdin io.Reader } func (c *cmd) init() { @@ -74,17 +77,23 @@ func (c *cmd) Run(args []string) int { } } + input := c.filePath + + if input == "" && len(c.flags.Args()) > 0 { + input = c.flags.Arg(0) + } + var parsedResource *pbresource.Resource - if c.filePath != "" { - data, err := resource.ParseResourceFromFile(c.filePath) + 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: Flag -f with file path argument is required") + c.UI.Error("Incorrect argument format: Must provide exactly one positional argument to specify the resource to write") return 1 } @@ -151,31 +160,42 @@ func (c *cmd) Help() string { const synopsis = "Writes/updates resource information" const help = ` -Usage: consul resource apply -f= +Usage: consul resource apply [options] -Write and/or update a resource by providing the definition in an hcl file as an argument + 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: + Example (with flag): -$ consul resource apply -f=demo.hcl + $ consul resource apply -f=demo.hcl -Sample demo.hcl: + Example (from file): -ID { - Type = gvk("group.version.kind") - Name = "resource-name" - Tenancy { - Namespace = "default" - Partition = "default" - PeerName = "local" + $ consul resource apply demo.hcl + + Example (from stdin): + + $ consul resource apply - + + Sample demo.hcl: + + ID { + Type = gvk("group.version.kind") + Name = "resource-name" + Tenancy { + Namespace = "default" + Partition = "default" + PeerName = "local" + } } - } - Data { - Name = "demo" - } + Data { + Name = "demo" + } - Metadata = { - "foo" = "bar" - } + Metadata = { + "foo" = "bar" + } ` diff --git a/command/resource/apply/apply_test.go b/command/resource/apply/apply_test.go index 6f9f3b1568..2644e7aaeb 100644 --- a/command/resource/apply/apply_test.go +++ b/command/resource/apply/apply_test.go @@ -5,12 +5,14 @@ package apply import ( "errors" + "io" "testing" "github.com/mitchellh/cli" "github.com/stretchr/testify/require" "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/command/resource/read" "github.com/hashicorp/consul/testrpc" ) @@ -39,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 { @@ -61,6 +68,129 @@ 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") + } + + t.Parallel() + + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + testrpc.WaitForTestAgent(t, a.RPC, "dc1") + + 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 { + Namespace = "default" + Partition = "default" + PeerName = "local" + } + } + + Data { + Name = "Korn" + Genre = "GENRE_METAL" + } + + Metadata = { + "foo" = "bar" + }` + + go func() { + stdinW.Write([]byte(stdInput)) + stdinW.Close() + }() + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-token=root", + "-", + } + + code := c.Run(args) + require.Equal(t, 0, code) + 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.") + 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": { + "namespace": "default", + "partition": "default", + "peerName": "local" + }, + "type": { + "group": "demo", + "groupVersion": "v2", + "kind": "Artist" + } + }, + "metadata": { + "foo": "bar" + } + }` + + go func() { + stdinW.Write([]byte(stdInput)) + stdinW.Close() + }() + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-token=root", + "-", + } + + code := c.Run(args) + require.Equal(t, 0, code) + 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.") + require.Contains(t, ui.OutputWriter.String(), expected) + }) +} + func TestResourceApplyInvalidArgs(t *testing.T) { t.Parallel() @@ -79,7 +209,7 @@ func TestResourceApplyInvalidArgs(t *testing.T) { "missing required flag": { args: []string{}, expectedCode: 1, - expectedErr: errors.New("Incorrect argument format: Flag -f with file path argument is required"), + 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"}, diff --git a/command/resource/helper.go b/command/resource/helper.go index 6e07c9f98d..417144ac78 100644 --- a/command/resource/helper.go +++ b/command/resource/helper.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "fmt" + "io" "net/http" "strings" "unicode" @@ -135,6 +136,25 @@ func isHCL(v []byte) bool { 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) {