From 3145bf52300f0f22a8e1377cccce629c435ef58e Mon Sep 17 00:00:00 2001 From: Matt Keeler Date: Tue, 30 Apr 2019 19:27:16 -0400 Subject: [PATCH] Centralized Config CLI (#5731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add HTTP endpoints for config entry management * Finish implementing decoding in the HTTP Config entry apply endpoint * Add CAS operation to the config entry apply endpoint Also use this for the bootstrapping and move the config entry decoding function into the structs package. * First pass at the API client for the config entries * Fixup some of the ConfigEntry APIs Return a singular response object instead of a list for the ConfigEntry.Get RPC. This gets plumbed through the HTTP API as well. Dont return QueryMeta in the JSON response for the config entry listing HTTP API. Instead just return a list of config entries. * Minor API client fixes * Attempt at some ConfigEntry api client tests These don’t currently work due to weak typing in JSON * Get some of the api client tests passing * Implement reflectwalk magic to correct JSON encoding a ProxyConfigEntry Also added a test for the HTTP endpoint that exposes the problem. However, since the test doesn’t actually do the JSON encode/decode its still failing. * Move MapWalk magic into a binary marshaller instead of JSON. * Add a MapWalk test * Get rid of unused func * Get rid of unused imports * Fixup some tests now that the decoding from msgpack coerces things into json compat types * Stub out most of the central config cli Fully implement the config read command. * Basic config delete command implementation * Implement config write command * Implement config list subcommand Not entirely sure about the output here. Its basically the read output indented with a line specifying the kind/name of each type which is also duplicated in the indented output. * Update command usage * Update some help usage formatting * Add the connect enable helper cli command * Update list command output * Rename the config entry API client methods. * Use renamed apis * Implement config write tests Stub the others with the noTabs tests. * Change list output format Now just simply output 1 line per named config * Add config read tests * Add invalid args write test. * Add config delete tests * Add config list tests * Add connect enable tests * Update some CLI commands to use CAS ops This also modifies the HTTP API for a write op to return a boolean indicating whether the value was written or not. * Fix up the HTTP API CAS tests as I realized they weren’t testing what they should. * Update config entry rpc tests to properly test CAS * Fix up a few more tests * Fix some tests that using ConfigEntries.Apply * Update config_write_test.go * Get rid of unused import --- agent/config_endpoint.go | 8 +- agent/config_endpoint_test.go | 24 +++- agent/consul/config_endpoint.go | 10 +- agent/consul/config_endpoint_test.go | 32 ++++- agent/consul/config_replication_test.go | 6 +- agent/consul/fsm/commands_oss.go | 5 +- agent/service_manager_test.go | 4 +- api/config_entry.go | 57 +++++++- api/config_entry_test.go | 43 +++++- command/commands_oss.go | 12 ++ command/config/config.go | 51 +++++++ command/config/delete/config_delete.go | 85 ++++++++++++ command/config/delete/config_delete_test.go | 67 +++++++++ command/config/list/config_list.go | 82 +++++++++++ command/config/list/config_list_test.go | 77 +++++++++++ command/config/read/config_read.go | 93 +++++++++++++ command/config/read/config_read_test.go | 69 ++++++++++ command/config/write/config_write.go | 130 ++++++++++++++++++ command/config/write/config_write_test.go | 106 ++++++++++++++ command/connect/enable/connect_enable.go | 101 ++++++++++++++ command/connect/enable/connect_enable_test.go | 64 +++++++++ command/helpers/helpers.go | 43 ++++-- sdk/testutil/io.go | 1 + 23 files changed, 1130 insertions(+), 40 deletions(-) create mode 100644 command/config/config.go create mode 100644 command/config/delete/config_delete.go create mode 100644 command/config/delete/config_delete_test.go create mode 100644 command/config/list/config_list.go create mode 100644 command/config/list/config_list_test.go create mode 100644 command/config/read/config_read.go create mode 100644 command/config/read/config_read_test.go create mode 100644 command/config/write/config_write.go create mode 100644 command/config/write/config_write_test.go create mode 100644 command/connect/enable/connect_enable.go create mode 100644 command/connect/enable/connect_enable_test.go diff --git a/agent/config_endpoint.go b/agent/config_endpoint.go index 18b7130ab9..2f290e66ef 100644 --- a/agent/config_endpoint.go +++ b/agent/config_endpoint.go @@ -121,6 +121,10 @@ func (s *HTTPServer) ConfigApply(resp http.ResponseWriter, req *http.Request) (i args.Entry.GetRaftIndex().ModifyIndex = casVal } - var reply struct{} - return nil, s.agent.RPC("ConfigEntry.Apply", &args, &reply) + var reply bool + if err := s.agent.RPC("ConfigEntry.Apply", &args, &reply); err != nil { + return nil, err + } + + return reply, nil } diff --git a/agent/config_endpoint_test.go b/agent/config_endpoint_test.go index 3c13a4b232..60dd3c64f2 100644 --- a/agent/config_endpoint_test.go +++ b/agent/config_endpoint_test.go @@ -46,7 +46,7 @@ func TestConfig_Get(t *testing.T) { }, } for _, req := range reqs { - var out struct{} + out := false require.NoError(t, a.RPC("ConfigEntry.Apply", &req, &out)) } @@ -117,7 +117,7 @@ func TestConfig_Delete(t *testing.T) { }, } for _, req := range reqs { - var out struct{} + out := false require.NoError(a.RPC("ConfigEntry.Apply", &req, &out)) } @@ -218,10 +218,20 @@ func TestConfig_Apply_CAS(t *testing.T) { require.NotNil(out.Entry) entry := out.Entry.(*structs.ServiceConfigEntry) + body = bytes.NewBuffer([]byte(` + { + "Kind": "service-defaults", + "Name": "foo", + "Protocol": "udp" + } + `)) req, _ = http.NewRequest("PUT", "/v1/config?cas=0", body) resp = httptest.NewRecorder() - _, err = a.srv.ConfigApply(resp, req) - require.Error(err) + writtenRaw, err := a.srv.ConfigApply(resp, req) + require.NoError(err) + written, ok := writtenRaw.(bool) + require.True(ok) + require.False(written) require.EqualValues(200, resp.Code, resp.Body.String()) body = bytes.NewBuffer([]byte(` @@ -231,11 +241,13 @@ func TestConfig_Apply_CAS(t *testing.T) { "Protocol": "udp" } `)) - req, _ = http.NewRequest("PUT", fmt.Sprintf("/v1/config?cas=%d", entry.GetRaftIndex().ModifyIndex), body) resp = httptest.NewRecorder() - _, err = a.srv.ConfigApply(resp, req) + writtenRaw, err = a.srv.ConfigApply(resp, req) require.NoError(err) + written, ok = writtenRaw.(bool) + require.True(ok) + require.True(written) require.EqualValues(200, resp.Code, resp.Body.String()) // Get the entry remaining entry. diff --git a/agent/consul/config_endpoint.go b/agent/consul/config_endpoint.go index 59a0e429f6..a932515479 100644 --- a/agent/consul/config_endpoint.go +++ b/agent/consul/config_endpoint.go @@ -17,7 +17,7 @@ type ConfigEntry struct { } // Apply does an upsert of the given config entry. -func (c *ConfigEntry) Apply(args *structs.ConfigEntryRequest, reply *struct{}) error { +func (c *ConfigEntry) Apply(args *structs.ConfigEntryRequest, reply *bool) error { if done, err := c.srv.forward("ConfigEntry.Apply", args, args, reply); done { return err } @@ -40,7 +40,9 @@ func (c *ConfigEntry) Apply(args *structs.ConfigEntryRequest, reply *struct{}) e return acl.ErrPermissionDenied } - args.Op = structs.ConfigEntryUpsert + if args.Op != structs.ConfigEntryUpsert && args.Op != structs.ConfigEntryUpsertCAS { + args.Op = structs.ConfigEntryUpsert + } resp, err := c.srv.raftApply(structs.ConfigEntryRequestType, args) if err != nil { return err @@ -48,6 +50,10 @@ func (c *ConfigEntry) Apply(args *structs.ConfigEntryRequest, reply *struct{}) e if respErr, ok := resp.(error); ok { return respErr } + if respBool, ok := resp.(bool); ok { + *reply = respBool + } + return nil } diff --git a/agent/consul/config_endpoint_test.go b/agent/consul/config_endpoint_test.go index a21f0eb2dd..0f1514745e 100644 --- a/agent/consul/config_endpoint_test.go +++ b/agent/consul/config_endpoint_test.go @@ -28,8 +28,9 @@ func TestConfigEntry_Apply(t *testing.T) { Name: "foo", }, } - var out struct{} + out := false require.NoError(msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &args, &out)) + require.True(out) state := s1.fsm.State() _, entry, err := state.ConfigEntry(nil, structs.ServiceDefaults, "foo") @@ -39,6 +40,33 @@ func TestConfigEntry_Apply(t *testing.T) { require.True(ok) require.Equal("foo", serviceConf.Name) require.Equal(structs.ServiceDefaults, serviceConf.Kind) + + args = structs.ConfigEntryRequest{ + Datacenter: "dc1", + Op: structs.ConfigEntryUpsertCAS, + Entry: &structs.ServiceConfigEntry{ + Name: "foo", + Protocol: "tcp", + }, + } + + require.NoError(msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &args, &out)) + require.False(out) + + args.Entry.GetRaftIndex().ModifyIndex = serviceConf.ModifyIndex + require.NoError(msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &args, &out)) + require.True(out) + + state = s1.fsm.State() + _, entry, err = state.ConfigEntry(nil, structs.ServiceDefaults, "foo") + require.NoError(err) + + serviceConf, ok = entry.(*structs.ServiceConfigEntry) + require.True(ok) + require.Equal("foo", serviceConf.Name) + require.Equal("tcp", serviceConf.Protocol) + require.Equal(structs.ServiceDefaults, serviceConf.Kind) + } func TestConfigEntry_Apply_ACLDeny(t *testing.T) { @@ -87,7 +115,7 @@ operator = "write" }, WriteRequest: structs.WriteRequest{Token: id}, } - var out struct{} + out := false err := msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &args, &out) if !acl.IsErrPermissionDenied(err) { t.Fatalf("err: %v", err) diff --git a/agent/consul/config_replication_test.go b/agent/consul/config_replication_test.go index d53892b9b3..8fa2c4f59d 100644 --- a/agent/consul/config_replication_test.go +++ b/agent/consul/config_replication_test.go @@ -51,7 +51,7 @@ func TestReplication_ConfigEntries(t *testing.T) { }, } - var out struct{} + out := false require.NoError(t, s1.RPC("ConfigEntry.Apply", &arg, &out)) entries = append(entries, arg.Entry) } @@ -69,7 +69,7 @@ func TestReplication_ConfigEntries(t *testing.T) { }, } - var out struct{} + out := false require.NoError(t, s1.RPC("ConfigEntry.Apply", &arg, &out)) entries = append(entries, arg.Entry) @@ -123,7 +123,7 @@ func TestReplication_ConfigEntries(t *testing.T) { }, } - var out struct{} + out := false require.NoError(t, s1.RPC("ConfigEntry.Apply", &arg, &out)) } diff --git a/agent/consul/fsm/commands_oss.go b/agent/consul/fsm/commands_oss.go index d1d86d2f01..47f807a642 100644 --- a/agent/consul/fsm/commands_oss.go +++ b/agent/consul/fsm/commands_oss.go @@ -457,7 +457,10 @@ func (c *FSM) applyConfigEntryOperation(buf []byte, index uint64) interface{} { case structs.ConfigEntryUpsert: defer metrics.MeasureSinceWithLabels([]string{"fsm", "config_entry", req.Entry.GetKind()}, time.Now(), []metrics.Label{{Name: "op", Value: "upsert"}}) - return c.state.EnsureConfigEntry(index, req.Entry) + if err := c.state.EnsureConfigEntry(index, req.Entry); err != nil { + return err + } + return true case structs.ConfigEntryDelete: defer metrics.MeasureSinceWithLabels([]string{"fsm", "config_entry", req.Entry.GetKind()}, time.Now(), []metrics.Label{{Name: "op", Value: "delete"}}) diff --git a/agent/service_manager_test.go b/agent/service_manager_test.go index 9fc2b21967..53d05b8e24 100644 --- a/agent/service_manager_test.go +++ b/agent/service_manager_test.go @@ -25,7 +25,7 @@ func TestServiceManager_RegisterService(t *testing.T) { }, }, } - var out struct{} + out := false require.NoError(a.RPC("ConfigEntry.Apply", args, &out)) // Now register a service locally and make sure the resulting State entry @@ -71,7 +71,7 @@ func TestServiceManager_Disabled(t *testing.T) { }, }, } - var out struct{} + out := false require.NoError(a.RPC("ConfigEntry.Apply", args, &out)) // Now register a service locally and make sure the resulting State entry diff --git a/api/config_entry.go b/api/config_entry.go index 934874aacc..e6f3a6ada0 100644 --- a/api/config_entry.go +++ b/api/config_entry.go @@ -1,7 +1,12 @@ package api import ( + "bytes" + "encoding/json" "fmt" + "io" + "strconv" + "strings" "github.com/mitchellh/mapstructure" ) @@ -15,6 +20,8 @@ const ( type ConfigEntry interface { GetKind() string GetName() string + GetCreateIndex() uint64 + GetModifyIndex() uint64 } type ConnectConfiguration struct { @@ -38,6 +45,14 @@ func (s *ServiceConfigEntry) GetName() string { return s.Name } +func (s *ServiceConfigEntry) GetCreateIndex() uint64 { + return s.CreateIndex +} + +func (s *ServiceConfigEntry) GetModifyIndex() uint64 { + return s.ModifyIndex +} + type ProxyConfigEntry struct { Kind string Name string @@ -54,6 +69,14 @@ func (p *ProxyConfigEntry) GetName() string { return p.Name } +func (p *ProxyConfigEntry) GetCreateIndex() uint64 { + return p.CreateIndex +} + +func (p *ProxyConfigEntry) GetModifyIndex() uint64 { + return p.ModifyIndex +} + type rawEntryListResponse struct { kind string Entries []map[string]interface{} @@ -105,6 +128,15 @@ func DecodeConfigEntry(raw map[string]interface{}) (ConfigEntry, error) { return entry, decoder.Decode(raw) } +func DecodeConfigEntryFromJSON(data []byte) (ConfigEntry, error) { + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + return DecodeConfigEntry(raw) +} + // Config can be used to query the Config endpoints type ConfigEntries struct { c *Client @@ -180,18 +212,35 @@ func (conf *ConfigEntries) List(kind string, q *QueryOptions) ([]ConfigEntry, *Q return entries, qm, nil } -func (conf *ConfigEntries) Set(entry ConfigEntry, w *WriteOptions) (*WriteMeta, error) { +func (conf *ConfigEntries) Set(entry ConfigEntry, w *WriteOptions) (bool, *WriteMeta, error) { + return conf.set(entry, nil, w) +} + +func (conf *ConfigEntries) CAS(entry ConfigEntry, index uint64, w *WriteOptions) (bool, *WriteMeta, error) { + return conf.set(entry, map[string]string{"cas": strconv.FormatUint(index, 10)}, w) +} + +func (conf *ConfigEntries) set(entry ConfigEntry, params map[string]string, w *WriteOptions) (bool, *WriteMeta, error) { r := conf.c.newRequest("PUT", "/v1/config") r.setWriteOptions(w) + for param, value := range params { + r.params.Set(param, value) + } r.obj = entry rtt, resp, err := requireOK(conf.c.doRequest(r)) if err != nil { - return nil, err + return false, nil, err } - resp.Body.Close() + defer resp.Body.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, resp.Body); err != nil { + return false, nil, fmt.Errorf("Failed to read response: %v", err) + } + res := strings.Contains(buf.String(), "true") wm := &WriteMeta{RequestTime: rtt} - return wm, nil + return res, wm, nil } func (conf *ConfigEntries) Delete(kind string, name string, w *WriteOptions) (*WriteMeta, error) { diff --git a/api/config_entry_test.go b/api/config_entry_test.go index 9913a19881..a2102d5747 100644 --- a/api/config_entry_test.go +++ b/api/config_entry_test.go @@ -24,7 +24,7 @@ func TestAPI_ConfigEntries(t *testing.T) { } // set it - wm, err := config_entries.Set(global_proxy, nil) + _, wm, err := config_entries.Set(global_proxy, nil) require.NoError(t, err) require.NotNil(t, wm) require.NotEqual(t, 0, wm.RequestTime) @@ -42,9 +42,23 @@ func TestAPI_ConfigEntries(t *testing.T) { require.Equal(t, global_proxy.Name, readProxy.Name) require.Equal(t, global_proxy.Config, readProxy.Config) - // update it global_proxy.Config["baz"] = true - wm, err = config_entries.Set(global_proxy, nil) + // CAS update fail + written, _, err := config_entries.CAS(global_proxy, 0, nil) + require.NoError(t, err) + require.False(t, written) + + // CAS update success + written, wm, err = config_entries.CAS(global_proxy, readProxy.ModifyIndex, nil) + require.NoError(t, err) + require.NotNil(t, wm) + require.NotEqual(t, 0, wm.RequestTime) + require.NoError(t, err) + require.True(t, written) + + // Non CAS update + global_proxy.Config["baz"] = "baz" + _, wm, err = config_entries.Set(global_proxy, nil) require.NoError(t, err) require.NotNil(t, wm) require.NotEqual(t, 0, wm.RequestTime) @@ -85,13 +99,13 @@ func TestAPI_ConfigEntries(t *testing.T) { } // set it - wm, err := config_entries.Set(service, nil) + _, wm, err := config_entries.Set(service, nil) require.NoError(t, err) require.NotNil(t, wm) require.NotEqual(t, 0, wm.RequestTime) // also set the second one - wm, err = config_entries.Set(service2, nil) + _, wm, err = config_entries.Set(service2, nil) require.NoError(t, err) require.NotNil(t, wm) require.NotEqual(t, 0, wm.RequestTime) @@ -111,7 +125,23 @@ func TestAPI_ConfigEntries(t *testing.T) { // update it service.Protocol = "tcp" - wm, err = config_entries.Set(service, nil) + + // CAS fail + written, _, err := config_entries.CAS(service, 0, nil) + require.NoError(t, err) + require.False(t, written) + + // CAS success + written, wm, err = config_entries.CAS(service, readService.ModifyIndex, nil) + require.NoError(t, err) + require.NotNil(t, wm) + require.NotEqual(t, 0, wm.RequestTime) + require.True(t, written) + + // update no cas + service.Connect.SidecarProxy = true + + _, wm, err = config_entries.Set(service, nil) require.NoError(t, err) require.NotNil(t, wm) require.NotEqual(t, 0, wm.RequestTime) @@ -133,6 +163,7 @@ func TestAPI_ConfigEntries(t *testing.T) { require.Equal(t, service.Kind, readService.Kind) require.Equal(t, service.Name, readService.Name) require.Equal(t, service.Protocol, readService.Protocol) + require.Equal(t, service.Connect.SidecarProxy, readService.Connect.SidecarProxy) case "bar": readService, ok = entry.(*ServiceConfigEntry) require.True(t, ok) diff --git a/command/commands_oss.go b/command/commands_oss.go index 89c3a2d408..79f31e0834 100644 --- a/command/commands_oss.go +++ b/command/commands_oss.go @@ -41,10 +41,16 @@ import ( catlistdc "github.com/hashicorp/consul/command/catalog/list/dc" catlistnodes "github.com/hashicorp/consul/command/catalog/list/nodes" catlistsvc "github.com/hashicorp/consul/command/catalog/list/services" + "github.com/hashicorp/consul/command/config" + configdelete "github.com/hashicorp/consul/command/config/delete" + configlist "github.com/hashicorp/consul/command/config/list" + configread "github.com/hashicorp/consul/command/config/read" + configwrite "github.com/hashicorp/consul/command/config/write" "github.com/hashicorp/consul/command/connect" "github.com/hashicorp/consul/command/connect/ca" caget "github.com/hashicorp/consul/command/connect/ca/get" caset "github.com/hashicorp/consul/command/connect/ca/set" + connectenable "github.com/hashicorp/consul/command/connect/enable" "github.com/hashicorp/consul/command/connect/envoy" "github.com/hashicorp/consul/command/connect/proxy" "github.com/hashicorp/consul/command/debug" @@ -151,10 +157,16 @@ func init() { Register("catalog datacenters", func(ui cli.Ui) (cli.Command, error) { return catlistdc.New(ui), nil }) Register("catalog nodes", func(ui cli.Ui) (cli.Command, error) { return catlistnodes.New(ui), nil }) Register("catalog services", func(ui cli.Ui) (cli.Command, error) { return catlistsvc.New(ui), nil }) + Register("config", func(ui cli.Ui) (cli.Command, error) { return config.New(), nil }) + Register("config delete", func(ui cli.Ui) (cli.Command, error) { return configdelete.New(ui), nil }) + Register("config list", func(ui cli.Ui) (cli.Command, error) { return configlist.New(ui), nil }) + Register("config read", func(ui cli.Ui) (cli.Command, error) { return configread.New(ui), nil }) + Register("config write", func(ui cli.Ui) (cli.Command, error) { return configwrite.New(ui), nil }) Register("connect", func(ui cli.Ui) (cli.Command, error) { return connect.New(), nil }) Register("connect ca", func(ui cli.Ui) (cli.Command, error) { return ca.New(), nil }) Register("connect ca get-config", func(ui cli.Ui) (cli.Command, error) { return caget.New(ui), nil }) Register("connect ca set-config", func(ui cli.Ui) (cli.Command, error) { return caset.New(ui), nil }) + Register("connect enable", func(ui cli.Ui) (cli.Command, error) { return connectenable.New(ui), nil }) Register("connect proxy", func(ui cli.Ui) (cli.Command, error) { return proxy.New(ui, MakeShutdownCh()), nil }) Register("connect envoy", func(ui cli.Ui) (cli.Command, error) { return envoy.New(ui), nil }) Register("debug", func(ui cli.Ui) (cli.Command, error) { return debug.New(ui, MakeShutdownCh()), nil }) diff --git a/command/config/config.go b/command/config/config.go new file mode 100644 index 0000000000..3e07dd1907 --- /dev/null +++ b/command/config/config.go @@ -0,0 +1,51 @@ +package config + +import ( + "github.com/hashicorp/consul/command/flags" + "github.com/mitchellh/cli" +) + +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 Centralized Configurations" +const help = ` +Usage: consul config [options] [args] + + This command has subcommands for interacting with Consul's Centralized + Configuration system. Here are some simple examples, and more detailed + examples are available in the subcommands or the documentation. + + Write a config:: + + $ consul config write web.serviceconf.hcl + + Read a config: + + $ consul config read -kind service-defaults -name web + + List all configs for a type: + + $ consul config list -kind service-defaults + + Delete a config: + + $ consul config delete -kind service-defaults -name web + + For more examples, ask for subcommand help or view the documentation. +` diff --git a/command/config/delete/config_delete.go b/command/config/delete/config_delete.go new file mode 100644 index 0000000000..2f6cddbfbf --- /dev/null +++ b/command/config/delete/config_delete.go @@ -0,0 +1,85 @@ +package delete + +import ( + "flag" + "fmt" + + "github.com/hashicorp/consul/command/flags" + "github.com/mitchellh/cli" +) + +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 + + kind string + name string +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.StringVar(&c.kind, "kind", "", "The kind of configuration to delete.") + c.flags.StringVar(&c.name, "name", "", "The name of configuration to delete.") + c.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + return 1 + } + + if c.kind == "" { + c.UI.Error("Must specify the -kind parameter") + return 1 + } + + if c.name == "" { + c.UI.Error("Must specify the -name parameter") + return 1 + } + + client, err := c.http.APIClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) + return 1 + } + + _, err = client.ConfigEntries().Delete(c.kind, c.name, nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Error deleting config entry %q / %q: %v", c.kind, c.name, err)) + return 1 + } + + // TODO (mkeeler) should we output anything when successful + return 0 +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(help, nil) +} + +const synopsis = "Delete a centralized config entry" +const help = ` +Usage: consul config delete [options] -kind -name + + Deletes the configuration entry specified by the kind and name. + + Example: + + $ consul config delete -kind service-defaults -name web +` diff --git a/command/config/delete/config_delete_test.go b/command/config/delete/config_delete_test.go new file mode 100644 index 0000000000..e43c3c51de --- /dev/null +++ b/command/config/delete/config_delete_test.go @@ -0,0 +1,67 @@ +package delete + +import ( + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestConfigDelete_noTabs(t *testing.T) { + t.Parallel() + + require.NotContains(t, New(cli.NewMockUi()).Help(), "\t") +} + +func TestConfigDelete(t *testing.T) { + t.Parallel() + + a := agent.NewTestAgent(t, t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + _, _, err := client.ConfigEntries().Set(&api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: "web", + Protocol: "tcp", + }, nil) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-kind=" + api.ServiceDefaults, + "-name=web", + } + + code := c.Run(args) + require.Equal(t, 0, code) + require.Empty(t, ui.OutputWriter.String()) + require.Empty(t, ui.ErrorWriter.String()) + + entry, _, err := client.ConfigEntries().Get(api.ServiceDefaults, "web", nil) + require.Error(t, err) + require.Nil(t, entry) +} + +func TestConfigDelete_InvalidArgs(t *testing.T) { + t.Parallel() + + cases := map[string][]string{ + "no kind": []string{}, + "no name": []string{"-kind", "service-defaults"}, + } + + for name, tcase := range cases { + t.Run(name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + require.NotEqual(t, 0, c.Run(tcase)) + require.NotEmpty(t, ui.ErrorWriter.String()) + }) + } +} diff --git a/command/config/list/config_list.go b/command/config/list/config_list.go new file mode 100644 index 0000000000..40a7f72fd0 --- /dev/null +++ b/command/config/list/config_list.go @@ -0,0 +1,82 @@ +package list + +import ( + "flag" + "fmt" + + "github.com/hashicorp/consul/command/flags" + "github.com/mitchellh/cli" +) + +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 + + kind string +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.StringVar(&c.kind, "kind", "", "The kind of configurations to list.") + c.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + return 1 + } + + if c.kind == "" { + c.UI.Error("Must specify the -kind parameter") + return 1 + } + + client, err := c.http.APIClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) + return 1 + } + + entries, _, err := client.ConfigEntries().List(c.kind, nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing config entries for kind %q: %v", c.kind, err)) + return 1 + } + + for _, entry := range entries { + c.UI.Info(entry.GetName()) + } + return 0 +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "List centralized config entries of a given kind" +const help = ` +Usage: consul config list [options] -kind + + Lists all of the config entries for a given kind. The -kind parameter + is required. + + Example: + + $ consul config list -kind service-defaults + +` diff --git a/command/config/list/config_list_test.go b/command/config/list/config_list_test.go new file mode 100644 index 0000000000..13e6c1ef1e --- /dev/null +++ b/command/config/list/config_list_test.go @@ -0,0 +1,77 @@ +package list + +import ( + "strings" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestConfigList_noTabs(t *testing.T) { + t.Parallel() + + require.NotContains(t, New(cli.NewMockUi()).Help(), "\t") +} + +func TestConfigList(t *testing.T) { + a := agent.NewTestAgent(t, t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + _, _, err := client.ConfigEntries().Set(&api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: "web", + Protocol: "tcp", + }, nil) + require.NoError(t, err) + + _, _, err = client.ConfigEntries().Set(&api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: "foo", + Protocol: "tcp", + }, nil) + require.NoError(t, err) + + _, _, err = client.ConfigEntries().Set(&api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: "api", + Protocol: "tcp", + }, nil) + require.NoError(t, err) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-kind=" + api.ServiceDefaults, + } + + code := c.Run(args) + require.Equal(t, 0, code) + + services := strings.Split(strings.Trim(ui.OutputWriter.String(), "\n"), "\n") + + require.ElementsMatch(t, []string{"web", "foo", "api"}, services) +} + +func TestConfigList_InvalidArgs(t *testing.T) { + t.Parallel() + + cases := map[string][]string{ + "no kind": []string{}, + } + + for name, tcase := range cases { + t.Run(name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + require.NotEqual(t, 0, c.Run(tcase)) + require.NotEmpty(t, ui.ErrorWriter.String()) + }) + } +} diff --git a/command/config/read/config_read.go b/command/config/read/config_read.go new file mode 100644 index 0000000000..ec3fbf54b2 --- /dev/null +++ b/command/config/read/config_read.go @@ -0,0 +1,93 @@ +package read + +import ( + "encoding/json" + "flag" + "fmt" + + "github.com/hashicorp/consul/command/flags" + "github.com/mitchellh/cli" +) + +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 + + kind string + name string +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.StringVar(&c.kind, "kind", "", "The kind of configuration to read.") + c.flags.StringVar(&c.name, "name", "", "The name of configuration to read.") + c.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + return 1 + } + + if c.kind == "" { + c.UI.Error("Must specify the -kind parameter") + return 1 + } + + if c.name == "" { + c.UI.Error("Must specify the -name parameter") + return 1 + } + + 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.ConfigEntries().Get(c.kind, c.name, nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading config entry %q / %q: %v", c.kind, c.name, 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 (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Read a centralized config entry" +const help = ` +Usage: consul config read [options] -kind -name + + Reads the config entry specified by the given kind and name and outputs its + JSON representation. + + Example: + + $ consul config read -kind proxy-defaults -name global +` diff --git a/command/config/read/config_read_test.go b/command/config/read/config_read_test.go new file mode 100644 index 0000000000..9c42f9486a --- /dev/null +++ b/command/config/read/config_read_test.go @@ -0,0 +1,69 @@ +package read + +import ( + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestConfigRead_noTabs(t *testing.T) { + t.Parallel() + + require.NotContains(t, New(cli.NewMockUi()).Help(), "\t") +} + +func TestConfigRead(t *testing.T) { + t.Parallel() + + a := agent.NewTestAgent(t, t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + _, _, err := client.ConfigEntries().Set(&api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: "web", + Protocol: "tcp", + }, nil) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-kind=" + api.ServiceDefaults, + "-name=web", + } + + code := c.Run(args) + require.Equal(t, 0, code) + + entry, err := api.DecodeConfigEntryFromJSON(ui.OutputWriter.Bytes()) + require.NoError(t, err) + svc, ok := entry.(*api.ServiceConfigEntry) + require.True(t, ok) + require.Equal(t, api.ServiceDefaults, svc.Kind) + require.Equal(t, "web", svc.Name) + require.Equal(t, "tcp", svc.Protocol) +} + +func TestConfigRead_InvalidArgs(t *testing.T) { + t.Parallel() + + cases := map[string][]string{ + "no kind": []string{}, + "no name": []string{"-kind", "service-defaults"}, + } + + for name, tcase := range cases { + t.Run(name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + require.NotEqual(t, 0, c.Run(tcase)) + require.NotEmpty(t, ui.ErrorWriter.String()) + }) + } +} diff --git a/command/config/write/config_write.go b/command/config/write/config_write.go new file mode 100644 index 0000000000..418b49921b --- /dev/null +++ b/command/config/write/config_write.go @@ -0,0 +1,130 @@ +package write + +import ( + "flag" + "fmt" + "io" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/consul/command/helpers" + "github.com/hashicorp/hcl" + "github.com/mitchellh/cli" +) + +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 + + cas bool + modifyIndex uint64 + testStdin io.Reader +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.http = &flags.HTTPFlags{} + c.flags.BoolVar(&c.cas, "cas", false, + "Perform a Check-And-Set operation. Specifying this value also "+ + "requires the -modify-index flag to be set. The default value "+ + "is false.") + c.flags.Uint64Var(&c.modifyIndex, "modify-index", 0, + "Unsigned integer representing the ModifyIndex of the config entry. "+ + "This is used in combination with the -cas flag.") + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + return 1 + } + + args = c.flags.Args() + if len(args) != 1 { + c.UI.Error("Must provide exactly one positional argument to specify the config entry to write") + return 1 + } + + data, err := helpers.LoadDataSourceNoRaw(args[0], c.testStdin) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to load data: %v", err)) + return 1 + } + + // parse the data + var raw map[string]interface{} + err = hcl.Decode(&raw, data) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to decode config entry input: %v", err)) + return 1 + } + + entry, err := api.DecodeConfigEntry(raw) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to decode config entry input: %v", 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 + } + + entries := client.ConfigEntries() + + written := false + if c.cas { + written, _, err = entries.CAS(entry, c.modifyIndex, nil) + } else { + written, _, err = entries.Set(entry, nil) + } + if err != nil { + c.UI.Error(fmt.Sprintf("Error writing config entry %q / %q: %v", entry.GetKind(), entry.GetName(), err)) + return 1 + } + + if !written { + c.UI.Error(fmt.Sprintf("Config entry %q / %q not updated", entry.GetKind(), entry.GetName())) + return 1 + } + + // TODO (mkeeler) should we output anything when successful + return 0 +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Create or update a centralized config entry" +const help = ` +Usage: consul config write [options] + + Request a config entry to be created or updated. The configuration + argument is either a file path or '-' to indicate that the config + should be read from stdin. The data should be either in HCL or + JSON form. + + Example (from file): + + $ consul config write web.service.hcl + + Example (from stdin): + + $ consul config write - +` diff --git a/command/config/write/config_write_test.go b/command/config/write/config_write_test.go new file mode 100644 index 0000000000..19a170838f --- /dev/null +++ b/command/config/write/config_write_test.go @@ -0,0 +1,106 @@ +package write + +import ( + "io" + "os" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestConfigWrite_noTabs(t *testing.T) { + t.Parallel() + + require.NotContains(t, New(cli.NewMockUi()).Help(), "\t") +} + +func TestConfigWrite(t *testing.T) { + t.Parallel() + + a := agent.NewTestAgent(t, t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + t.Run("File", func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + f := testutil.TempFile(t, "config-write-svc-web.hcl") + defer os.Remove(f.Name()) + _, err := f.WriteString(` + Kind = "service-defaults" + Name = "web" + Protocol = "udp" + `) + + require.NoError(t, err) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + f.Name(), + } + + code := c.Run(args) + require.Empty(t, ui.ErrorWriter.String()) + require.Equal(t, 0, code) + + entry, _, err := client.ConfigEntries().Get("service-defaults", "web", nil) + require.NoError(t, err) + svc, ok := entry.(*api.ServiceConfigEntry) + require.True(t, ok) + require.Equal(t, api.ServiceDefaults, svc.Kind) + require.Equal(t, "web", svc.Name) + require.Equal(t, "udp", svc.Protocol) + }) + + t.Run("Stdin", func(t *testing.T) { + stdinR, stdinW := io.Pipe() + + ui := cli.NewMockUi() + c := New(ui) + c.testStdin = stdinR + + go func() { + stdinW.Write([]byte(`{ + "Kind": "proxy-defaults", + "Name": "global", + "Config": { + "foo": "bar", + "bar": 1.0 + } + }`)) + stdinW.Close() + }() + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-", + } + + code := c.Run(args) + require.Empty(t, ui.ErrorWriter.String()) + require.Equal(t, 0, code) + + entry, _, err := client.ConfigEntries().Get(api.ProxyDefaults, api.ProxyConfigGlobal, nil) + require.NoError(t, err) + proxy, ok := entry.(*api.ProxyConfigEntry) + require.True(t, ok) + require.Equal(t, api.ProxyDefaults, proxy.Kind) + require.Equal(t, api.ProxyConfigGlobal, proxy.Name) + require.Equal(t, map[string]interface{}{"foo": "bar", "bar": 1.0}, proxy.Config) + }) + + t.Run("No config", func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + code := c.Run([]string{}) + require.NotEqual(t, 0, code) + require.NotEmpty(t, ui.ErrorWriter.String()) + }) + +} diff --git a/command/connect/enable/connect_enable.go b/command/connect/enable/connect_enable.go new file mode 100644 index 0000000000..3f4ed591d3 --- /dev/null +++ b/command/connect/enable/connect_enable.go @@ -0,0 +1,101 @@ +package enable + +import ( + "flag" + "fmt" + "io" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/flags" + "github.com/mitchellh/cli" +) + +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 + + service string + protocol string + sidecarProxy bool + + testStdin io.Reader +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.http = &flags.HTTPFlags{} + c.flags.BoolVar(&c.sidecarProxy, "sidecar-proxy", false, "Whether the service should have a Sidecar Proxy by default") + c.flags.StringVar(&c.service, "service", "", "The service to enable connect for") + c.flags.StringVar(&c.protocol, "protocol", "", "The protocol spoken by the service") + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + return 1 + } + + if c.service == "" { + c.UI.Error("Must specify the -service parameter") + return 1 + } + + entry := &api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: c.service, + Protocol: c.protocol, + Connect: api.ConnectConfiguration{ + SidecarProxy: c.sidecarProxy, + }, + } + + client, err := c.http.APIClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) + return 1 + } + + written, _, err := client.ConfigEntries().Set(entry, nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Error writing config entry %q / %q: %v", entry.GetKind(), entry.GetName(), err)) + return 1 + } + + if !written { + c.UI.Error(fmt.Sprintf("Config entry %q / %q not updated", entry.GetKind(), entry.GetName())) + return 1 + } + + // TODO (mkeeler) should we output anything when successful + return 0 + +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const synopsis = "Sets some simple Connect related configuration for a service" +const help = ` +Usage: consul connect enable -service [options] + + Sets up some Connect related service defaults. + + Example: + + $ consul connect enable -service web -protocol http -sidecar-proxy true +` diff --git a/command/connect/enable/connect_enable_test.go b/command/connect/enable/connect_enable_test.go new file mode 100644 index 0000000000..bd822c6fde --- /dev/null +++ b/command/connect/enable/connect_enable_test.go @@ -0,0 +1,64 @@ +package enable + +import ( + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestConnectEnable_noTabs(t *testing.T) { + t.Parallel() + + require.NotContains(t, New(cli.NewMockUi()).Help(), "\t") +} + +func TestConnectEnable(t *testing.T) { + t.Parallel() + + a := agent.NewTestAgent(t, t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-service=web", + "-protocol=tcp", + "-sidecar-proxy=true", + } + + code := c.Run(args) + require.Equal(t, 0, code) + + entry, _, err := client.ConfigEntries().Get(api.ServiceDefaults, "web", nil) + require.NoError(t, err) + svc, ok := entry.(*api.ServiceConfigEntry) + require.True(t, ok) + require.Equal(t, api.ServiceDefaults, svc.Kind) + require.Equal(t, "web", svc.Name) + require.Equal(t, "tcp", svc.Protocol) + require.True(t, svc.Connect.SidecarProxy) +} + +func TestConnectEnable_InvalidArgs(t *testing.T) { + t.Parallel() + + cases := map[string][]string{ + "no service": []string{}, + } + + for name, tcase := range cases { + t.Run(name, func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + require.NotEqual(t, 0, c.Run(tcase)) + require.NotEmpty(t, ui.ErrorWriter.String()) + }) + } +} diff --git a/command/helpers/helpers.go b/command/helpers/helpers.go index 6ad7ed2b7d..965f261bd6 100644 --- a/command/helpers/helpers.go +++ b/command/helpers/helpers.go @@ -8,12 +8,28 @@ import ( "os" ) -func LoadDataSource(data string, testStdin io.Reader) (string, error) { +func loadFromFile(path string) (string, error) { + data, err := ioutil.ReadFile(path) + if err != nil { + return "", fmt.Errorf("Failed to read file: %v", err) + } + return string(data), nil +} + +func loadFromStdin(testStdin io.Reader) (string, error) { var stdin io.Reader = os.Stdin if testStdin != nil { stdin = testStdin } + var b bytes.Buffer + if _, err := io.Copy(&b, stdin); err != nil { + return "", fmt.Errorf("Failed to read stdin: %v", err) + } + return b.String(), nil +} + +func LoadDataSource(data string, testStdin io.Reader) (string, error) { // Handle empty quoted shell parameters if len(data) == 0 { return "", nil @@ -21,22 +37,25 @@ func LoadDataSource(data string, testStdin io.Reader) (string, error) { switch data[0] { case '@': - data, err := ioutil.ReadFile(data[1:]) - if err != nil { - return "", fmt.Errorf("Failed to read file: %s", err) - } else { - return string(data), nil - } + return loadFromFile(data[1:]) case '-': if len(data) > 1 { return data, nil } - var b bytes.Buffer - if _, err := io.Copy(&b, stdin); err != nil { - return "", fmt.Errorf("Failed to read stdin: %s", err) - } - return b.String(), nil + return loadFromStdin(testStdin) default: return data, nil } } + +func LoadDataSourceNoRaw(data string, testStdin io.Reader) (string, error) { + if len(data) == 0 { + return "", fmt.Errorf("Failed to load data: must specify a file path or '-' for stdin") + } + + if data == "-" { + return loadFromStdin(testStdin) + } + + return loadFromFile(data) +} diff --git a/sdk/testutil/io.go b/sdk/testutil/io.go index 77041d47d8..a137fc6a3f 100644 --- a/sdk/testutil/io.go +++ b/sdk/testutil/io.go @@ -56,6 +56,7 @@ func TempFile(t *testing.T, name string) *os.File { if t != nil && t.Name() != "" { name = t.Name() + "-" + name } + name = strings.Replace(name, "/", "_", -1) f, err := ioutil.TempFile(tmpdir, name) if err != nil { if t == nil {