diff --git a/command/force_leave.go b/command/force_leave.go new file mode 100644 index 000000000..329ffa0f7 --- /dev/null +++ b/command/force_leave.go @@ -0,0 +1,69 @@ +package command + +import ( + "flag" + "fmt" + "github.com/mitchellh/cli" + "strings" +) + +// ForceLeaveCommand is a Command implementation that tells a running Consul +// to force a member to enter the "left" state. +type ForceLeaveCommand struct { + Ui cli.Ui +} + +func (c *ForceLeaveCommand) Run(args []string) int { + cmdFlags := flag.NewFlagSet("join", flag.ContinueOnError) + cmdFlags.Usage = func() { c.Ui.Output(c.Help()) } + rpcAddr := RPCAddrFlag(cmdFlags) + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + nodes := cmdFlags.Args() + if len(nodes) != 1 { + c.Ui.Error("A node name must be specified to force leave.") + c.Ui.Error("") + c.Ui.Error(c.Help()) + return 1 + } + + client, err := RPCClient(*rpcAddr) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + defer client.Close() + + err = client.ForceLeave(nodes[0]) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error force leaving: %s", err)) + return 1 + } + + return 0 +} + +func (c *ForceLeaveCommand) Synopsis() string { + return "Forces a member of the cluster to enter the \"left\" state" +} + +func (c *ForceLeaveCommand) Help() string { + helpText := ` +Usage: consul force-leave [options] name + + Forces a member of a Consul cluster to enter the "left" state. Note + that if the member is still actually alive, it will eventually rejoin + the cluster. This command is most useful for cleaning out "failed" nodes + that are never coming back. If you do not force leave a failed node, + Consul will attempt to reconnect to those failed nodes for some period of + time before eventually reaping them. + +Options: + + -rpc-addr=127.0.0.1:7373 RPC address of the Consul agent. + +` + return strings.TrimSpace(helpText) +} diff --git a/command/force_leave_test.go b/command/force_leave_test.go new file mode 100644 index 000000000..f98757bbc --- /dev/null +++ b/command/force_leave_test.go @@ -0,0 +1,71 @@ +package command + +import ( + "fmt" + "github.com/hashicorp/serf/serf" + "github.com/hashicorp/serf/testutil" + "github.com/mitchellh/cli" + "strings" + "testing" + "time" +) + +func TestForceLeaveCommand_implements(t *testing.T) { + var _ cli.Command = &ForceLeaveCommand{} +} + +func TestForceLeaveCommandRun(t *testing.T) { + a1 := testAgent(t) + a2 := testAgent(t) + defer a1.Shutdown() + defer a2.Shutdown() + + addr := fmt.Sprintf("127.0.0.1:%d", a2.config.SerfLanPort) + _, err := a1.agent.JoinLAN([]string{addr}) + if err != nil { + t.Fatalf("err: %s", err) + } + + testutil.Yield() + + // Forcibly shutdown a2 so that it appears "failed" in a1 + a2.Shutdown() + + time.Sleep(time.Second) + + ui := new(cli.MockUi) + c := &ForceLeaveCommand{Ui: ui} + args := []string{ + "-rpc-addr=" + a1.addr, + a2.config.NodeName, + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + m := a1.agent.LANMembers() + if len(m) != 2 { + t.Fatalf("should have 2 members: %#v", m) + } + + if m[1].Status != serf.StatusLeft { + t.Fatalf("should be left: %#v", m[1]) + } +} + +func TestForceLeaveCommandRun_noAddrs(t *testing.T) { + ui := new(cli.MockUi) + c := &ForceLeaveCommand{Ui: ui} + args := []string{"-rpc-addr=foo"} + + code := c.Run(args) + if code != 1 { + t.Fatalf("bad: %d", code) + } + + if !strings.Contains(ui.ErrorWriter.String(), "node name") { + t.Fatalf("bad: %#v", ui.ErrorWriter.String()) + } +} diff --git a/command/join.go b/command/join.go new file mode 100644 index 000000000..d0e52a1c5 --- /dev/null +++ b/command/join.go @@ -0,0 +1,70 @@ +package command + +import ( + "flag" + "fmt" + "github.com/mitchellh/cli" + "strings" +) + +// JoinCommand is a Command implementation that tells a running Consul +// agent to join another. +type JoinCommand struct { + Ui cli.Ui +} + +func (c *JoinCommand) Help() string { + helpText := ` +Usage: consul join [options] address ... + + Tells a running Consul agent (with "consul agent") to join the cluster + by specifying at least one existing member. + +Options: + + -rpc-addr=127.0.0.1:7373 RPC address of the Consul agent. + -wan Joins a server to another server in the WAN pool +` + return strings.TrimSpace(helpText) +} + +func (c *JoinCommand) Run(args []string) int { + var wan bool + + cmdFlags := flag.NewFlagSet("join", flag.ContinueOnError) + cmdFlags.Usage = func() { c.Ui.Output(c.Help()) } + cmdFlags.BoolVar(&wan, "wan", false, "wan") + rpcAddr := RPCAddrFlag(cmdFlags) + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + addrs := cmdFlags.Args() + if len(addrs) == 0 { + c.Ui.Error("At least one address to join must be specified.") + c.Ui.Error("") + c.Ui.Error(c.Help()) + return 1 + } + + client, err := RPCClient(*rpcAddr) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + defer client.Close() + + n, err := client.Join(addrs, wan) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error joining the cluster: %s", err)) + return 1 + } + + c.Ui.Output(fmt.Sprintf( + "Successfully joined cluster by contacting %d nodes.", n)) + return 0 +} + +func (c *JoinCommand) Synopsis() string { + return "Tell Consul agent to join cluster" +} diff --git a/command/join_test.go b/command/join_test.go new file mode 100644 index 000000000..d71237785 --- /dev/null +++ b/command/join_test.go @@ -0,0 +1,74 @@ +package command + +import ( + "fmt" + "github.com/mitchellh/cli" + "strings" + "testing" +) + +func TestJoinCommand_implements(t *testing.T) { + var _ cli.Command = &JoinCommand{} +} + +func TestJoinCommandRun(t *testing.T) { + a1 := testAgent(t) + a2 := testAgent(t) + defer a1.Shutdown() + defer a2.Shutdown() + + ui := new(cli.MockUi) + c := &JoinCommand{Ui: ui} + args := []string{ + "-rpc-addr=" + a1.addr, + fmt.Sprintf("127.0.0.1:%d", a2.config.SerfLanPort), + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if len(a1.agent.LANMembers()) != 2 { + t.Fatalf("bad: %#v", a1.agent.LANMembers()) + } +} + +func TestJoinCommandRun_wan(t *testing.T) { + a1 := testAgent(t) + a2 := testAgent(t) + defer a1.Shutdown() + defer a2.Shutdown() + + ui := new(cli.MockUi) + c := &JoinCommand{Ui: ui} + args := []string{ + "-rpc-addr=" + a1.addr, + "-wan", + fmt.Sprintf("127.0.0.1:%d", a2.config.SerfWanPort), + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if len(a1.agent.WANMembers()) != 2 { + t.Fatalf("bad: %#v", a1.agent.WANMembers()) + } +} + +func TestJoinCommandRun_noAddrs(t *testing.T) { + ui := new(cli.MockUi) + c := &JoinCommand{Ui: ui} + args := []string{"-rpc-addr=foo"} + + code := c.Run(args) + if code != 1 { + t.Fatalf("bad: %d", code) + } + + if !strings.Contains(ui.ErrorWriter.String(), "one address") { + t.Fatalf("bad: %#v", ui.ErrorWriter.String()) + } +} diff --git a/command/keygen.go b/command/keygen.go new file mode 100644 index 000000000..0bb4c5db8 --- /dev/null +++ b/command/keygen.go @@ -0,0 +1,46 @@ +package command + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "github.com/mitchellh/cli" + "strings" +) + +// KeygenCommand is a Command implementation that generates an encryption +// key for use in `consul agent`. +type KeygenCommand struct { + Ui cli.Ui +} + +func (c *KeygenCommand) Run(_ []string) int { + key := make([]byte, 16) + n, err := rand.Reader.Read(key) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error reading random data: %s", err)) + return 1 + } + if n != 16 { + c.Ui.Error(fmt.Sprintf("Couldn't read enough entropy. Generate more entropy!")) + return 1 + } + + c.Ui.Output(base64.StdEncoding.EncodeToString(key)) + return 0 +} + +func (c *KeygenCommand) Synopsis() string { + return "Generates a new encryption key" +} + +func (c *KeygenCommand) Help() string { + helpText := ` +Usage: consul keygen + + Generates a new encryption key that can be used to configure the + agent to encrypt traffic. The output of this command is already + in the proper format that the agent expects. +` + return strings.TrimSpace(helpText) +} diff --git a/command/keygen_test.go b/command/keygen_test.go new file mode 100644 index 000000000..f94392538 --- /dev/null +++ b/command/keygen_test.go @@ -0,0 +1,30 @@ +package command + +import ( + "encoding/base64" + "github.com/mitchellh/cli" + "testing" +) + +func TestKeygenCommand_implements(t *testing.T) { + var _ cli.Command = &KeygenCommand{} +} + +func TestKeygenCommand(t *testing.T) { + ui := new(cli.MockUi) + c := &KeygenCommand{Ui: ui} + code := c.Run(nil) + if code != 0 { + t.Fatalf("bad: %d", code) + } + + output := ui.OutputWriter.String() + result, err := base64.StdEncoding.DecodeString(output) + if err != nil { + t.Fatalf("err: %s", err) + } + + if len(result) != 16 { + t.Fatalf("bad: %#v", result) + } +} diff --git a/command/leave.go b/command/leave.go new file mode 100644 index 000000000..03318dcb9 --- /dev/null +++ b/command/leave.go @@ -0,0 +1,55 @@ +package command + +import ( + "flag" + "fmt" + "github.com/mitchellh/cli" + "strings" +) + +// LeaveCommand is a Command implementation that instructs +// the Consul agent to gracefully leave the cluster +type LeaveCommand struct { + Ui cli.Ui +} + +func (c *LeaveCommand) Help() string { + helpText := ` +Usage: consul leave + + Causes the agent to gracefully leave the Consul cluster and shutdown. + +Options: + + -rpc-addr=127.0.0.1:7373 RPC address of the Consul agent. +` + return strings.TrimSpace(helpText) +} + +func (c *LeaveCommand) Run(args []string) int { + cmdFlags := flag.NewFlagSet("leave", flag.ContinueOnError) + cmdFlags.Usage = func() { c.Ui.Output(c.Help()) } + rpcAddr := RPCAddrFlag(cmdFlags) + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + client, err := RPCClient(*rpcAddr) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + defer client.Close() + + if err := client.Leave(); err != nil { + c.Ui.Error(fmt.Sprintf("Error leaving: %s", err)) + return 1 + } + + c.Ui.Output("Graceful leave complete") + return 0 +} + +func (c *LeaveCommand) Synopsis() string { + return "Gracefully leaves the Consul cluster and shuts down" +} diff --git a/command/leave_test.go b/command/leave_test.go new file mode 100644 index 000000000..2a5caaccd --- /dev/null +++ b/command/leave_test.go @@ -0,0 +1,29 @@ +package command + +import ( + "github.com/mitchellh/cli" + "strings" + "testing" +) + +func TestLeaveCommand_implements(t *testing.T) { + var _ cli.Command = &LeaveCommand{} +} + +func TestLeaveCommandRun(t *testing.T) { + a1 := testAgent(t) + defer a1.Shutdown() + + ui := new(cli.MockUi) + c := &LeaveCommand{Ui: ui} + args := []string{"-rpc-addr=" + a1.addr} + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if !strings.Contains(ui.OutputWriter.String(), "leave complete") { + t.Fatalf("bad: %#v", ui.OutputWriter.String()) + } +} diff --git a/command/members.go b/command/members.go new file mode 100644 index 000000000..9373c8ad0 --- /dev/null +++ b/command/members.go @@ -0,0 +1,111 @@ +package command + +import ( + "flag" + "fmt" + "github.com/hashicorp/consul/command/agent" + "github.com/mitchellh/cli" + "net" + "regexp" + "strings" +) + +// MembersCommand is a Command implementation that queries a running +// Consul agent what members are part of the cluster currently. +type MembersCommand struct { + Ui cli.Ui +} + +func (c *MembersCommand) Help() string { + helpText := ` +Usage: consul members [options] + + Outputs the members of a running Consul agent. + +Options: + + -detailed Additional information such as protocol verions + will be shown. + + -role= If provided, output is filtered to only nodes matching + the regular expression for role + + -rpc-addr=127.0.0.1:7373 RPC address of the Consul agent. + + -status= If provided, output is filtered to only nodes matching + the regular expression for status + + -wan If the agent is in server mode, this can be used to return + the other peers in the WAN pool +` + return strings.TrimSpace(helpText) +} + +func (c *MembersCommand) Run(args []string) int { + var detailed, wan bool + var roleFilter, statusFilter string + cmdFlags := flag.NewFlagSet("members", flag.ContinueOnError) + cmdFlags.Usage = func() { c.Ui.Output(c.Help()) } + cmdFlags.BoolVar(&detailed, "detailed", false, "detailed output") + cmdFlags.BoolVar(&wan, "wan", false, "wan members") + cmdFlags.StringVar(&roleFilter, "role", ".*", "role filter") + cmdFlags.StringVar(&statusFilter, "status", ".*", "status filter") + rpcAddr := RPCAddrFlag(cmdFlags) + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + // Compile the regexp + roleRe, err := regexp.Compile(roleFilter) + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to compile role regexp: %v", err)) + return 1 + } + statusRe, err := regexp.Compile(statusFilter) + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to compile status regexp: %v", err)) + return 1 + } + + client, err := RPCClient(*rpcAddr) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + defer client.Close() + + var members []agent.Member + if wan { + members, err = client.WANMembers() + } else { + members, err = client.LANMembers() + } + if err != nil { + c.Ui.Error(fmt.Sprintf("Error retrieving members: %s", err)) + return 1 + } + + for _, member := range members { + // Skip the non-matching members + if !roleRe.MatchString(member.Role) || !statusRe.MatchString(member.Status) { + continue + } + + addr := net.TCPAddr{IP: member.Addr, Port: int(member.Port)} + c.Ui.Output(fmt.Sprintf("%s %s %s %s", + member.Name, addr.String(), member.Status, member.Role)) + + if detailed { + c.Ui.Output(fmt.Sprintf(" Protocol Version: %d", + member.DelegateCur)) + c.Ui.Output(fmt.Sprintf(" Available Protocol Range: [%d, %d]", + member.DelegateMin, member.DelegateMax)) + } + } + + return 0 +} + +func (c *MembersCommand) Synopsis() string { + return "Lists the members of a Consul cluster" +} diff --git a/command/members_test.go b/command/members_test.go new file mode 100644 index 000000000..420753f98 --- /dev/null +++ b/command/members_test.go @@ -0,0 +1,132 @@ +package command + +import ( + "fmt" + "github.com/mitchellh/cli" + "strings" + "testing" +) + +func TestMembersCommand_implements(t *testing.T) { + var _ cli.Command = &MembersCommand{} +} + +func TestMembersCommandRun(t *testing.T) { + a1 := testAgent(t) + defer a1.Shutdown() + + ui := new(cli.MockUi) + c := &MembersCommand{Ui: ui} + args := []string{"-rpc-addr=" + a1.addr} + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if !strings.Contains(ui.OutputWriter.String(), a1.config.NodeName) { + t.Fatalf("bad: %#v", ui.OutputWriter.String()) + } +} + +func TestMembersCommandRun_WAN(t *testing.T) { + a1 := testAgent(t) + defer a1.Shutdown() + + ui := new(cli.MockUi) + c := &MembersCommand{Ui: ui} + args := []string{"-rpc-addr=" + a1.addr, "-wan"} + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if !strings.Contains(ui.OutputWriter.String(), fmt.Sprintf("%d", a1.config.SerfWanPort)) { + t.Fatalf("bad: %#v", ui.OutputWriter.String()) + } +} + +func TestMembersCommandRun_statusFilter(t *testing.T) { + a1 := testAgent(t) + defer a1.Shutdown() + + ui := new(cli.MockUi) + c := &MembersCommand{Ui: ui} + args := []string{ + "-rpc-addr=" + a1.addr, + "-status=a.*e", + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if !strings.Contains(ui.OutputWriter.String(), a1.config.NodeName) { + t.Fatalf("bad: %#v", ui.OutputWriter.String()) + } +} + +func TestMembersCommandRun_statusFilter_failed(t *testing.T) { + a1 := testAgent(t) + defer a1.Shutdown() + + ui := new(cli.MockUi) + c := &MembersCommand{Ui: ui} + args := []string{ + "-rpc-addr=" + a1.addr, + "-status=(fail|left)", + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if strings.Contains(ui.OutputWriter.String(), a1.config.NodeName) { + t.Fatalf("bad: %#v", ui.OutputWriter.String()) + } +} + +func TestMembersCommandRun_roleFilter(t *testing.T) { + a1 := testAgent(t) + defer a1.Shutdown() + + ui := new(cli.MockUi) + c := &MembersCommand{Ui: ui} + args := []string{ + "-rpc-addr=" + a1.addr, + "-role=consul", + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if !strings.Contains(ui.OutputWriter.String(), a1.config.NodeName) { + t.Fatalf("bad: %#v", ui.OutputWriter.String()) + } +} + +func TestMembersCommandRun_roleFilter_failed(t *testing.T) { + a1 := testAgent(t) + defer a1.Shutdown() + + ui := new(cli.MockUi) + c := &MembersCommand{Ui: ui} + args := []string{ + "-rpc-addr=" + a1.addr, + "-role=primary", + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + if strings.Contains(ui.OutputWriter.String(), a1.config.NodeName) { + t.Fatalf("bad: %#v", ui.OutputWriter.String()) + } +} diff --git a/command/util_test.go b/command/util_test.go new file mode 100644 index 000000000..582a02193 --- /dev/null +++ b/command/util_test.go @@ -0,0 +1,101 @@ +package command + +import ( + "fmt" + "github.com/hashicorp/consul/command/agent" + "github.com/hashicorp/consul/consul" + "io" + "io/ioutil" + "math/rand" + "net" + "os" + "sync/atomic" + "testing" + "time" +) + +var offset uint64 + +func init() { + // Seed the random number generator + rand.Seed(time.Now().UnixNano()) +} + +type agentWrapper struct { + dir string + config *agent.Config + agent *agent.Agent + rpc *agent.AgentRPC + addr string +} + +func (a *agentWrapper) Shutdown() { + a.rpc.Shutdown() + a.agent.Shutdown() + os.RemoveAll(a.dir) +} + +func testAgent(t *testing.T) *agentWrapper { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("err: %s", err) + } + + lw := agent.NewLogWriter(512) + mult := io.MultiWriter(os.Stderr, lw) + + conf := nextConfig() + + dir, err := ioutil.TempDir("", "agent") + if err != nil { + t.Fatalf(fmt.Sprintf("err: %v", err)) + } + conf.DataDir = dir + + a, err := agent.Create(conf, lw) + if err != nil { + os.RemoveAll(dir) + t.Fatalf(fmt.Sprintf("err: %v", err)) + } + + rpc := agent.NewAgentRPC(a, l, mult, lw) + return &agentWrapper{ + dir: dir, + config: conf, + agent: a, + rpc: rpc, + addr: l.Addr().String(), + } +} + +func nextConfig() *agent.Config { + idx := atomic.AddUint64(&offset, 1) + conf := agent.DefaultConfig() + + conf.Bootstrap = true + conf.Datacenter = "dc1" + conf.NodeName = fmt.Sprintf("Node %d", idx) + conf.HTTPAddr = fmt.Sprintf("127.0.0.1:%d", 10000+10*idx) + conf.RPCAddr = fmt.Sprintf("127.0.0.1:%d", 10100+10*idx) + conf.SerfBindAddr = "127.0.0.1" + conf.SerfLanPort = int(10201 + 10*idx) + conf.SerfWanPort = int(10202 + 10*idx) + conf.Server = true + conf.ServerAddr = fmt.Sprintf("127.0.0.1:%d", 10300+10*idx) + + cons := consul.DefaultConfig() + conf.ConsulConfig = cons + + cons.SerfLANConfig.MemberlistConfig.ProbeTimeout = 100 * time.Millisecond + cons.SerfLANConfig.MemberlistConfig.ProbeInterval = 100 * time.Millisecond + cons.SerfLANConfig.MemberlistConfig.GossipInterval = 100 * time.Millisecond + + cons.SerfWANConfig.MemberlistConfig.ProbeTimeout = 100 * time.Millisecond + cons.SerfWANConfig.MemberlistConfig.ProbeInterval = 100 * time.Millisecond + cons.SerfWANConfig.MemberlistConfig.GossipInterval = 100 * time.Millisecond + + cons.RaftConfig.HeartbeatTimeout = 40 * time.Millisecond + cons.RaftConfig.ElectionTimeout = 40 * time.Millisecond + + return conf +} diff --git a/commands.go b/commands.go index 4840bc60e..c0d1241cf 100644 --- a/commands.go +++ b/commands.go @@ -22,6 +22,43 @@ func init() { }, nil }, + "force-leave": func() (cli.Command, error) { + return &command.ForceLeaveCommand{ + Ui: ui, + }, nil + }, + + "join": func() (cli.Command, error) { + return &command.JoinCommand{ + Ui: ui, + }, nil + }, + + "keygen": func() (cli.Command, error) { + return &command.KeygenCommand{ + Ui: ui, + }, nil + }, + + "leave": func() (cli.Command, error) { + return &command.LeaveCommand{ + Ui: ui, + }, nil + }, + + "members": func() (cli.Command, error) { + return &command.MembersCommand{ + Ui: ui, + }, nil + }, + + "monitor": func() (cli.Command, error) { + return &command.MonitorCommand{ + ShutdownCh: makeShutdownCh(), + Ui: ui, + }, nil + }, + "version": func() (cli.Command, error) { return &command.VersionCommand{ Revision: GitCommit,