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 {