diff --git a/api/agent.go b/api/agent.go index ae2eb1a61..358c2d80a 100644 --- a/api/agent.go +++ b/api/agent.go @@ -95,9 +95,10 @@ func (a *Agent) Region() (string, error) { // Join is used to instruct a server node to join another server // via the gossip protocol. Multiple addresses may be specified. -// We attempt to join all of the hosts in the list. If one or +// We attempt to join all of the hosts in the list. Returns the +// number of nodes successfully joined and any error. If one or // more nodes have a successful result, no error is returned. -func (a *Agent) Join(addrs ...string) error { +func (a *Agent) Join(addrs ...string) (int, error) { // Accumulate the addresses v := url.Values{} for _, addr := range addrs { @@ -108,12 +109,12 @@ func (a *Agent) Join(addrs ...string) error { var resp joinResponse _, err := a.client.write("/v1/agent/join?"+v.Encode(), nil, &resp, nil) if err != nil { - return fmt.Errorf("failed joining: %s", err) + return 0, fmt.Errorf("failed joining: %s", err) } if resp.Error != "" { - return fmt.Errorf("failed joining: %s", resp.Error) + return 0, fmt.Errorf("failed joining: %s", resp.Error) } - return nil + return resp.NumJoined, nil } // Members is used to query all of the known server members @@ -137,8 +138,8 @@ func (a *Agent) ForceLeave(node string) error { // joinResponse is used to decode the response we get while // sending a member join request. type joinResponse struct { - NumNodes int `json:"num_nodes"` - Error string `json:"error"` + NumJoined int `json:"num_joined"` + Error string `json:"error"` } // AgentMember represents a cluster member known to the agent @@ -147,7 +148,7 @@ type AgentMember struct { Addr string Port uint16 Tags map[string]string - Status int + Status string ProtocolMin uint8 ProtocolMax uint8 ProtocolCur uint8 diff --git a/api/agent_test.go b/api/agent_test.go index 7eb891ea1..3bccb269a 100644 --- a/api/agent_test.go +++ b/api/agent_test.go @@ -2,6 +2,8 @@ package api import ( "testing" + + "github.com/hashicorp/nomad/testutil" ) func TestAgent_Self(t *testing.T) { @@ -59,22 +61,32 @@ func TestAgent_Datacenter(t *testing.T) { } func TestAgent_Join(t *testing.T) { - c, s := makeClient(t, nil, nil) - defer s.Stop() - a := c.Agent() + c1, s1 := makeClient(t, nil, nil) + defer s1.Stop() + a1 := c1.Agent() + + _, s2 := makeClient(t, nil, func(c *testutil.TestServerConfig) { + c.Server.Bootstrap = false + }) + defer s2.Stop() // Attempting to join a non-existent host returns error - if err := a.Join("nope"); err == nil { + n, err := a1.Join("nope") + if err == nil { t.Fatalf("expected error, got nothing") } + if n != 0 { + t.Fatalf("expected 0 nodes, got: %d", n) + } - // TODO(ryanuber): This is pretty much a worthless test, - // since we are just joining ourselves. Once the agent - // respects config options, change this to actually make - // two nodes and join them. - if err := a.Join("127.0.0.1"); err != nil { + // Returns correctly if join succeeds + n, err = a1.Join(s2.SerfAddr) + if err != nil { t.Fatalf("err: %s", err) } + if n != 1 { + t.Fatalf("expected 1 node, got: %d", n) + } } func TestAgent_Members(t *testing.T) { diff --git a/api/api.go b/api/api.go index e1bb8e520..f37873c20 100644 --- a/api/api.go +++ b/api/api.go @@ -67,8 +67,8 @@ type WriteMeta struct { // Config is used to configure the creation of a client type Config struct { - // URL is the address of the Nomad agent - URL string + // Address is the address of the Nomad agent + Address string // Region to use. If not provided, the default agent region is used. Region string @@ -85,11 +85,11 @@ type Config struct { // DefaultConfig returns a default configuration for the client func DefaultConfig() *Config { config := &Config{ - URL: "http://127.0.0.1:4646", + Address: "http://127.0.0.1:4646", HttpClient: http.DefaultClient, } - if url := os.Getenv("NOMAD_HTTP_URL"); url != "" { - config.URL = url + if addr := os.Getenv("NOMAD_ADDR"); addr != "" { + config.Address = addr } return config } @@ -104,10 +104,10 @@ func NewClient(config *Config) (*Client, error) { // bootstrap the config defConfig := DefaultConfig() - if config.URL == "" { - config.URL = defConfig.URL - } else if _, err := url.Parse(config.URL); err != nil { - return nil, fmt.Errorf("invalid url '%s': %v", config.URL, err) + if config.Address == "" { + config.Address = defConfig.Address + } else if _, err := url.Parse(config.Address); err != nil { + return nil, fmt.Errorf("invalid address '%s': %v", config.Address, err) } if config.HttpClient == nil { @@ -194,7 +194,7 @@ func (r *request) toHTTP() (*http.Request, error) { // newRequest is used to create a new request func (c *Client) newRequest(method, path string) *request { - base, _ := url.Parse(c.config.URL) + base, _ := url.Parse(c.config.Address) u, _ := url.Parse(path) r := &request{ config: &c.config, diff --git a/api/api_test.go b/api/api_test.go index 37d6b6a13..8a64bd974 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -11,10 +11,21 @@ import ( type configCallback func(c *Config) +// seen is used to track which tests we have already marked as parallel +var seen map[*testing.T]struct{} + +func init() { + seen = make(map[*testing.T]struct{}) +} + func makeClient(t *testing.T, cb1 configCallback, cb2 testutil.ServerConfigCallback) (*Client, *testutil.TestServer) { - // Always run these tests in parallel - t.Parallel() + // Always run these tests in parallel. Check if we have already + // marked the current test, as more than 1 call causes panics. + if _, ok := seen[t]; !ok { + seen[t] = struct{}{} + t.Parallel() + } // Make client config conf := DefaultConfig() @@ -24,7 +35,7 @@ func makeClient(t *testing.T, cb1 configCallback, // Create server server := testutil.NewTestServer(t, cb2) - conf.URL = "http://" + server.HTTPAddr + conf.Address = "http://" + server.HTTPAddr // Create client client, err := NewClient(conf) @@ -39,13 +50,13 @@ func TestDefaultConfig_env(t *testing.T) { t.Parallel() url := "http://1.2.3.4:5678" - os.Setenv("NOMAD_HTTP_URL", url) - defer os.Setenv("NOMAD_HTTP_URL", "") + os.Setenv("NOMAD_ADDR", url) + defer os.Setenv("NOMAD_ADDR", "") config := DefaultConfig() - if config.URL != url { - t.Errorf("expected %q to be %q", config.URL, url) + if config.Address != url { + t.Errorf("expected %q to be %q", config.Address, url) } } diff --git a/command/agent/agent_endpoint.go b/command/agent/agent_endpoint.go index 3b5798b72..73e73e799 100644 --- a/command/agent/agent_endpoint.go +++ b/command/agent/agent_endpoint.go @@ -1,27 +1,57 @@ package agent import ( + "net" "net/http" "github.com/hashicorp/serf/serf" ) +type Member struct { + Name string + Addr net.IP + Port uint16 + Tags map[string]string + Status string + ProtocolMin uint8 + ProtocolMax uint8 + ProtocolCur uint8 + DelegateMin uint8 + DelegateMax uint8 + DelegateCur uint8 +} + +func nomadMember(m serf.Member) Member { + return Member{ + Name: m.Name, + Addr: m.Addr, + Port: m.Port, + Tags: m.Tags, + Status: m.Status.String(), + ProtocolMin: m.ProtocolMin, + ProtocolMax: m.ProtocolMax, + ProtocolCur: m.ProtocolCur, + DelegateMin: m.DelegateMin, + DelegateMax: m.DelegateMax, + DelegateCur: m.DelegateCur, + } +} + func (s *HTTPServer) AgentSelfRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { if req.Method != "GET" { return nil, CodedError(405, ErrInvalidMethod) } // Get the member as a server - var member *serf.Member + var member serf.Member srv := s.agent.Server() if srv != nil { - mem := srv.LocalMember() - member = &mem + member = srv.LocalMember() } self := agentSelf{ Config: s.agent.config, - Member: member, + Member: nomadMember(member), Stats: s.agent.Stats(), } return self, nil @@ -60,7 +90,13 @@ func (s *HTTPServer) AgentMembersRequest(resp http.ResponseWriter, req *http.Req if srv == nil { return nil, CodedError(501, ErrInvalidMethod) } - return srv.Members(), nil + + serfMembers := srv.Members() + members := make([]Member, len(serfMembers)) + for i, mem := range serfMembers { + members[i] = nomadMember(mem) + } + return members, nil } func (s *HTTPServer) AgentForceLeaveRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { @@ -85,7 +121,7 @@ func (s *HTTPServer) AgentForceLeaveRequest(resp http.ResponseWriter, req *http. type agentSelf struct { Config *Config `json:"config"` - Member *serf.Member `json:"member,omitempty"` + Member Member `json:"member,omitempty"` Stats map[string]map[string]string `json:"stats"` } diff --git a/command/agent/agent_endpoint_test.go b/command/agent/agent_endpoint_test.go index 2f372e2e4..f498f3c6d 100644 --- a/command/agent/agent_endpoint_test.go +++ b/command/agent/agent_endpoint_test.go @@ -5,8 +5,6 @@ import ( "net/http" "net/http/httptest" "testing" - - "github.com/hashicorp/serf/serf" ) func TestHTTP_AgentSelf(t *testing.T) { @@ -85,7 +83,7 @@ func TestHTTP_AgentMembers(t *testing.T) { } // Check the job - members := obj.([]serf.Member) + members := obj.([]Member) if len(members) != 1 { t.Fatalf("bad: %#v", members) } diff --git a/command/agent_force_leave.go b/command/agent_force_leave.go new file mode 100644 index 000000000..b1a26b98b --- /dev/null +++ b/command/agent_force_leave.go @@ -0,0 +1,60 @@ +package command + +import ( + "fmt" + "strings" +) + +type AgentForceLeaveCommand struct { + Meta +} + +func (c *AgentForceLeaveCommand) Help() string { + helpText := ` +Usage: nomad agent-force-leave [options] + + Forces an agent to enter the "left" state. This can be used to + eject nodes which have failed and will not rejoin the cluster. + Note that if the member is actually still alive, it will + eventually rejoin the cluster again. + +General Options: + + ` + generalOptionsUsage() + return strings.TrimSpace(helpText) +} + +func (c *AgentForceLeaveCommand) Synopsis() string { + return "Force a member into the 'left' state" +} + +func (c *AgentForceLeaveCommand) Run(args []string) int { + flags := c.Meta.FlagSet("force-leave", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got exactly one node + args = flags.Args() + if len(args) != 1 { + c.Ui.Error(c.Help()) + return 1 + } + node := args[0] + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Call force-leave on the node + if err := client.Agent().ForceLeave(node); err != nil { + c.Ui.Error(fmt.Sprintf("Error force-leaving node %s: %s", node, err)) + return 1 + } + + return 0 +} diff --git a/command/agent_force_leave_test.go b/command/agent_force_leave_test.go new file mode 100644 index 000000000..d343a09fc --- /dev/null +++ b/command/agent_force_leave_test.go @@ -0,0 +1,11 @@ +package command + +import ( + "testing" + + "github.com/mitchellh/cli" +) + +func TestAgentForceLeaveCommand_Implements(t *testing.T) { + var _ cli.Command = &AgentForceLeaveCommand{} +} diff --git a/command/agent_info.go b/command/agent_info.go new file mode 100644 index 000000000..a36d83539 --- /dev/null +++ b/command/agent_info.go @@ -0,0 +1,68 @@ +package command + +import ( + "fmt" + "strings" +) + +type AgentInfoCommand struct { + Meta +} + +func (c *AgentInfoCommand) Help() string { + helpText := ` +Usage: nomad agent-info [options] + + Display status information about the local agent. + +General Options: + + ` + generalOptionsUsage() + return strings.TrimSpace(helpText) +} + +func (c *AgentInfoCommand) Synopsis() string { + return "Display status information about the local agent" +} + +func (c *AgentInfoCommand) Run(args []string) int { + flags := c.Meta.FlagSet("agent-info", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we either got no jobs or exactly one. + args = flags.Args() + if len(args) > 0 { + c.Ui.Error(c.Help()) + return 1 + } + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Query the agent info + info, err := client.Agent().Self() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying agent info: %s", err)) + return 1 + } + + var stats map[string]interface{} + stats, _ = info["stats"] + + for section, data := range stats { + c.Ui.Output(section) + d, _ := data.(map[string]interface{}) + for k, v := range d { + c.Ui.Output(fmt.Sprintf(" %s = %v", k, v)) + } + } + + return 0 +} diff --git a/command/agent_info_test.go b/command/agent_info_test.go new file mode 100644 index 000000000..d9d7a18c2 --- /dev/null +++ b/command/agent_info_test.go @@ -0,0 +1,47 @@ +package command + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func TestAgentInfoCommand_Implements(t *testing.T) { + var _ cli.Command = &AgentInfoCommand{} +} + +func TestAgentInfoCommand_Run(t *testing.T) { + srv, _, url := testServer(t) + defer srv.Stop() + + ui := new(cli.MockUi) + cmd := &AgentInfoCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-address=" + url}) + if code != 0 { + t.Fatalf("expected exit 0, got: %d %s", code) + } +} + +func TestAgentInfoCommand_Fails(t *testing.T) { + ui := new(cli.MockUi) + cmd := &AgentInfoCommand{Meta: Meta{Ui: ui}} + + // Fails on misuse + if code := cmd.Run([]string{"some", "bad", "args"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, cmd.Help()) { + t.Fatalf("expected help output, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails on connection failure + if code := cmd.Run([]string{"-address=nope"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, "Error querying agent info") { + t.Fatalf("expected failed query error, got: %s", out) + } +} diff --git a/command/agent_join.go b/command/agent_join.go new file mode 100644 index 000000000..5da7e65a7 --- /dev/null +++ b/command/agent_join.go @@ -0,0 +1,64 @@ +package command + +import ( + "fmt" + "strings" +) + +type AgentJoinCommand struct { + Meta +} + +func (c *AgentJoinCommand) Help() string { + helpText := ` +Usage: nomad agent-join [options] [...] + + Joins the local server to one or more Nomad servers. Joining is + only required for server nodes, and only needs to succeed + against one or more of the provided addresses. Once joined, the + gossip layer will handle discovery of the other server nodes in + the cluster. + +General Options: + + ` + generalOptionsUsage() + return strings.TrimSpace(helpText) +} + +func (c *AgentJoinCommand) Synopsis() string { + return "Join server nodes together" +} + +func (c *AgentJoinCommand) Run(args []string) int { + flags := c.Meta.FlagSet("agent-join", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got at least one node + args = flags.Args() + if len(args) < 1 { + c.Ui.Error(c.Help()) + return 1 + } + nodes := args + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Attempt the join + n, err := client.Agent().Join(nodes...) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error joining: %s", err)) + return 1 + } + + // Success + c.Ui.Output(fmt.Sprintf("Joined %d nodes successfully", n)) + return 0 +} diff --git a/command/agent_join_test.go b/command/agent_join_test.go new file mode 100644 index 000000000..f8a278fc7 --- /dev/null +++ b/command/agent_join_test.go @@ -0,0 +1,11 @@ +package command + +import ( + "testing" + + "github.com/mitchellh/cli" +) + +func TestAgentJoinCommand_Implements(t *testing.T) { + var _ cli.Command = &AgentJoinCommand{} +} diff --git a/command/agent_members.go b/command/agent_members.go new file mode 100644 index 000000000..38110cb4d --- /dev/null +++ b/command/agent_members.go @@ -0,0 +1,121 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/ryanuber/columnize" +) + +type AgentMembersCommand struct { + Meta +} + +func (c *AgentMembersCommand) Help() string { + helpText := ` +Usage: nomad agent-members [options] + + Display a list of the known members and their status. + +General Options: + + ` + generalOptionsUsage() + ` + +Agent Members Options: + + -detailed + Show detailed information about each member. This dumps + a raw set of tags which shows more information than the + default output format. +` + return strings.TrimSpace(helpText) +} + +func (c *AgentMembersCommand) Synopsis() string { + return "Display a list of known members and their status" +} + +func (c *AgentMembersCommand) Run(args []string) int { + var detailed bool + + flags := c.Meta.FlagSet("agent-members", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.BoolVar(&detailed, "detailed", false, "Show detailed output") + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check for extra arguments + args = flags.Args() + if len(args) != 0 { + c.Ui.Error(c.Help()) + return 1 + } + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Query the members + mem, err := client.Agent().Members() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying members: %s", err)) + return 1 + } + + // Format the list + var out []string + if detailed { + out = detailedOutput(mem) + } else { + out = standardOutput(mem) + } + + // Dump the list + c.Ui.Output(columnize.SimpleFormat(out)) + return 0 +} + +func standardOutput(mem []*api.AgentMember) []string { + // Format the members list + members := make([]string, len(mem)+1) + members[0] = "Name|Addr|Port|Status|Proto|Build|DC|Region" + for i, member := range mem { + members[i+1] = fmt.Sprintf("%s|%s|%d|%s|%d|%s|%s|%s", + member.Name, + member.Addr, + member.Port, + member.Status, + member.ProtocolCur, + member.Tags["build"], + member.Tags["dc"], + member.Tags["region"]) + } + return members +} + +func detailedOutput(mem []*api.AgentMember) []string { + // Format the members list + members := make([]string, len(mem)+1) + members[0] = "Name|Addr|Port|Tags" + for i, member := range mem { + // Format the tags + tagPairs := make([]string, 0, len(member.Tags)) + for k, v := range member.Tags { + tagPairs = append(tagPairs, fmt.Sprintf("%s=%s", k, v)) + } + tags := strings.Join(tagPairs, ",") + + members[i+1] = fmt.Sprintf("%s|%s|%d|%s", + member.Name, + member.Addr, + member.Port, + tags) + } + return members +} diff --git a/command/agent_members_test.go b/command/agent_members_test.go new file mode 100644 index 000000000..0136e0dd5 --- /dev/null +++ b/command/agent_members_test.go @@ -0,0 +1,65 @@ +package command + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func TestAgentMembersCommand_Implements(t *testing.T) { + var _ cli.Command = &AgentMembersCommand{} +} + +func TestAgentMembersCommand_Run(t *testing.T) { + srv, client, url := testServer(t) + defer srv.Stop() + + ui := new(cli.MockUi) + cmd := &AgentMembersCommand{Meta: Meta{Ui: ui}} + + // Get our own node name + name, err := client.Agent().NodeName() + if err != nil { + t.Fatalf("err: %s", err) + } + + // Query the members + if code := cmd.Run([]string{"-address=" + url}); code != 0 { + t.Fatalf("expected exit 0, got: %d", code) + } + if out := ui.OutputWriter.String(); !strings.Contains(out, name) { + t.Fatalf("expected %q in output, got: %s", name, out) + } + ui.OutputWriter.Reset() + + // Query members with detailed output + if code := cmd.Run([]string{"-address=" + url, "-detailed"}); code != 0 { + t.Fatalf("expected exit 0, got: %d", code) + } + if out := ui.OutputWriter.String(); !strings.Contains(out, "Tags") { + t.Fatalf("expected tags in output, got: %s", out) + } +} + +func TestMembersCommand_Fails(t *testing.T) { + ui := new(cli.MockUi) + cmd := &AgentMembersCommand{Meta: Meta{Ui: ui}} + + // Fails on misuse + if code := cmd.Run([]string{"some", "bad", "args"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, cmd.Help()) { + t.Fatalf("expected help output, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails on connection failure + if code := cmd.Run([]string{"-address=nope"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, "Error querying members") { + t.Fatalf("expected failed query error, got: %s", out) + } +} diff --git a/command/meta.go b/command/meta.go index 2bc12104b..2c5e25ee0 100644 --- a/command/meta.go +++ b/command/meta.go @@ -4,18 +4,27 @@ import ( "bufio" "flag" "io" + "os" + "strings" + "github.com/hashicorp/nomad/api" "github.com/mitchellh/cli" ) +const ( + // Names of environment variables used to supply various + // config options to the Nomad CLI. + EnvNomadAddress = "NOMAD_ADDR" +) + // FlagSetFlags is an enum to define what flags are present in the // default FlagSet returned by Meta.FlagSet. type FlagSetFlags uint const ( FlagSetNone FlagSetFlags = 0 - FlagSetServer FlagSetFlags = 1 << iota - FlagSetDefault = FlagSetServer + FlagSetClient FlagSetFlags = 1 << iota + FlagSetDefault = FlagSetClient ) // Meta contains the meta-options and functionality that nearly every @@ -34,9 +43,9 @@ type Meta struct { func (m *Meta) FlagSet(n string, fs FlagSetFlags) *flag.FlagSet { f := flag.NewFlagSet(n, flag.ContinueOnError) - // FlagSetServer tells us to enable the settings for selecting - // the server information. - if fs&FlagSetServer != 0 { + // FlagSetClient is used to enable the settings for specifying + // client connectivity options. + if fs&FlagSetClient != 0 { f.StringVar(&m.flagAddress, "address", "", "") } @@ -55,3 +64,27 @@ func (m *Meta) FlagSet(n string, fs FlagSetFlags) *flag.FlagSet { return f } + +// Client is used to initialize and return a new API client using +// the default command line arguments and env vars. +func (m *Meta) Client() (*api.Client, error) { + config := api.DefaultConfig() + if v := os.Getenv(EnvNomadAddress); v != "" { + config.Address = v + } + if m.flagAddress != "" { + config.Address = m.flagAddress + } + return api.NewClient(config) +} + +// generalOptionsUsage returns the help string for the global options. +func generalOptionsUsage() string { + helpText := ` + -address= + The address of the Nomad server. + Overrides the NOMAD_ADDR environment variable if set. + Default = http://127.0.0.1:4646 +` + return strings.TrimSpace(helpText) +} diff --git a/command/meta_test.go b/command/meta_test.go index fb484ab35..979ce1161 100644 --- a/command/meta_test.go +++ b/command/meta_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestFlagSet(t *testing.T) { +func TestMeta_FlagSet(t *testing.T) { cases := []struct { Flags FlagSetFlags Expected []string @@ -17,7 +17,7 @@ func TestFlagSet(t *testing.T) { []string{}, }, { - FlagSetServer, + FlagSetClient, []string{"address"}, }, } diff --git a/command/node_drain.go b/command/node_drain.go new file mode 100644 index 000000000..efb0d13fe --- /dev/null +++ b/command/node_drain.go @@ -0,0 +1,77 @@ +package command + +import ( + "fmt" + "strings" +) + +type NodeDrainCommand struct { + Meta +} + +func (c *NodeDrainCommand) Help() string { + helpText := ` +Usage: nomad node-drain [options] + + Toggles node draining on a specified node. It is required + that either -enable or -disable is specified, but not both. + +General Options: + + ` + generalOptionsUsage() + ` + +Node Drain Options: + + -disable + Disable draining for the specified node. + + -enable + Enable draining for the specified node. +` + return strings.TrimSpace(helpText) +} + +func (c *NodeDrainCommand) Synopsis() string { + return "Toggle drain mode on a given node" +} + +func (c *NodeDrainCommand) Run(args []string) int { + var enable, disable bool + + flags := c.Meta.FlagSet("node-drain", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.BoolVar(&enable, "enable", false, "Enable drain mode") + flags.BoolVar(&disable, "disable", false, "Disable drain mode") + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got either enable or disable, but not both. + if (enable && disable) || (!enable && !disable) { + c.Ui.Error(c.Help()) + return 1 + } + + // Check that we got a node ID + args = flags.Args() + if len(args) != 1 { + c.Ui.Error(c.Help()) + return 1 + } + nodeID := args[0] + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Toggle node draining + if _, err := client.Nodes().ToggleDrain(nodeID, enable, nil); err != nil { + c.Ui.Error(fmt.Sprintf("Error toggling drain mode: %s", err)) + return 1 + } + return 0 +} diff --git a/command/node_drain_test.go b/command/node_drain_test.go new file mode 100644 index 000000000..11bd04da8 --- /dev/null +++ b/command/node_drain_test.go @@ -0,0 +1,64 @@ +package command + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func TestNodeDrainCommand_Implements(t *testing.T) { + var _ cli.Command = &NodeDrainCommand{} +} + +func TestNodeDrainCommand_Fails(t *testing.T) { + srv, _, url := testServer(t) + defer srv.Stop() + + ui := new(cli.MockUi) + cmd := &NodeDrainCommand{Meta: Meta{Ui: ui}} + + // Fails on misuse + if code := cmd.Run([]string{"some", "bad", "args"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, cmd.Help()) { + t.Fatalf("expected help output, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails on connection failure + if code := cmd.Run([]string{"-address=nope", "-enable", "nope"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, "Error toggling") { + t.Fatalf("expected failed toggle error, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails on non-existent node + if code := cmd.Run([]string{"-address=" + url, "-enable", "nope"}); code != 1 { + t.Fatalf("expected exit 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, "not found") { + t.Fatalf("expected not exist error, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails if both enable and disable specified + if code := cmd.Run([]string{"-enable", "-disable", "nope"}); code != 1 { + t.Fatalf("expected exit 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, cmd.Help()) { + t.Fatalf("expected help output, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails if neither enable or disable specified + if code := cmd.Run([]string{"nope"}); code != 1 { + t.Fatalf("expected exit 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, cmd.Help()) { + t.Fatalf("expected help output, got: %s", out) + } +} diff --git a/command/node_status.go b/command/node_status.go new file mode 100644 index 000000000..b70f07221 --- /dev/null +++ b/command/node_status.go @@ -0,0 +1,110 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/ryanuber/columnize" +) + +type NodeStatusCommand struct { + Meta +} + +func (c *NodeStatusCommand) Help() string { + helpText := ` +Usage: nomad node-status [options] [node] + + Display status information about a given node. The list of nodes + returned includes only nodes which jobs may be scheduled to, and + includes status and other high-level information. + + If a node ID is passed, information for that specific node will + be displayed. If no node ID's are passed, then a short-hand + list of all nodes will be displayed. + +General Options: + + ` + generalOptionsUsage() + return strings.TrimSpace(helpText) +} + +func (c *NodeStatusCommand) Synopsis() string { + return "Display status information about nodes" +} + +func (c *NodeStatusCommand) Run(args []string) int { + flags := c.Meta.FlagSet("node-status", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got either a single node or none + args = flags.Args() + if len(args) > 1 { + c.Ui.Error(c.Help()) + return 1 + } + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Use list mode if no node name was provided + if len(args) == 0 { + // Query the node info + nodes, _, err := client.Nodes().List(nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying node status: %s", err)) + return 1 + } + + // Return nothing if no nodes found + if len(nodes) == 0 { + return 0 + } + + // Format the nodes list + out := make([]string, len(nodes)+1) + out[0] = "ID|DC|Name|Class|Drain|Status" + for i, node := range nodes { + out[i+1] = fmt.Sprintf("%s|%s|%s|%s|%v|%s", + node.ID, + node.Datacenter, + node.Name, + node.NodeClass, + node.Drain, + node.Status) + } + + // Dump the output + c.Ui.Output(columnize.SimpleFormat(out)) + return 0 + } + + // Query the specific node + nodeID := args[0] + node, _, err := client.Nodes().Info(nodeID, nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying node info: %s", err)) + return 1 + } + + // Format the output + out := []string{ + fmt.Sprintf("ID | %s", node.ID), + fmt.Sprintf("Name | %s", node.Name), + fmt.Sprintf("Class | %s", node.NodeClass), + fmt.Sprintf("Datacenter | %s", node.Datacenter), + fmt.Sprintf("Drain | %v", node.Drain), + fmt.Sprintf("Status | %s", node.Status), + } + + // Dump the output + c.Ui.Output(columnize.SimpleFormat(out)) + return 0 +} diff --git a/command/node_status_test.go b/command/node_status_test.go new file mode 100644 index 000000000..5b8965641 --- /dev/null +++ b/command/node_status_test.go @@ -0,0 +1,63 @@ +package command + +import ( + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +func TestNodeStatusCommand_Implements(t *testing.T) { + var _ cli.Command = &NodeStatusCommand{} +} + +func TestNodeStatusCommand_Run(t *testing.T) { + srv, _, url := testServer(t) + defer srv.Stop() + + ui := new(cli.MockUi) + cmd := &NodeStatusCommand{Meta: Meta{Ui: ui}} + + // Query all node statuses + if code := cmd.Run([]string{"-address=" + url}); code != 0 { + t.Fatalf("expected exit 0, got: %d", code) + } + + // Expect empty output since we have no nodes + if out := ui.OutputWriter.String(); out != "" { + t.Fatalf("expected empty output, got: %s", out) + } +} + +func TestNodeStatusCommand_Fails(t *testing.T) { + srv, _, url := testServer(t) + defer srv.Stop() + + ui := new(cli.MockUi) + cmd := &NodeStatusCommand{Meta: Meta{Ui: ui}} + + // Fails on misuse + if code := cmd.Run([]string{"some", "bad", "args"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, cmd.Help()) { + t.Fatalf("expected help output, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails on connection failure + if code := cmd.Run([]string{"-address=nope"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, "Error querying node status") { + t.Fatalf("expected failed query error, got: %s", out) + } + + // Fails on non-existent node + if code := cmd.Run([]string{"-address=" + url, "nope"}); code != 1 { + t.Fatalf("expected exit 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, "not found") { + t.Fatalf("expected not found error, got: %s", out) + } +} diff --git a/command/status.go b/command/status.go new file mode 100644 index 000000000..dbe95ba35 --- /dev/null +++ b/command/status.go @@ -0,0 +1,99 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/ryanuber/columnize" +) + +type StatusCommand struct { + Meta +} + +func (c *StatusCommand) Help() string { + helpText := ` +Usage: nomad status [options] [job] + + Display status information about jobs. If no job ID is given, + a list of all known jobs will be dumped. + +General Options: + + ` + generalOptionsUsage() + return strings.TrimSpace(helpText) +} + +func (c *StatusCommand) Synopsis() string { + return "Display status information about jobs" +} + +func (c *StatusCommand) Run(args []string) int { + flags := c.Meta.FlagSet("status", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we either got no jobs or exactly one. + args = flags.Args() + if len(args) > 1 { + c.Ui.Error(c.Help()) + return 1 + } + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Invoke list mode if no job ID. + if len(args) == 0 { + jobs, _, err := client.Jobs().List(nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying jobs: %s", err)) + return 1 + } + + // No output if we have no jobs + if len(jobs) == 0 { + return 0 + } + + out := make([]string, len(jobs)+1) + out[0] = "ID|Type|Priority|Status" + for i, job := range jobs { + out[i+1] = fmt.Sprintf("%s|%s|%d|%s", + job.ID, + job.Type, + job.Priority, + job.Status) + } + c.Ui.Output(columnize.SimpleFormat(out)) + return 0 + } + + // Try querying the job + jobID := args[0] + job, _, err := client.Jobs().Info(jobID, nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying job: %s", err)) + return 1 + } + + // Format the job info + basic := []string{ + fmt.Sprintf("ID | %s", job.ID), + fmt.Sprintf("Name | %s", job.Name), + fmt.Sprintf("Type | %s", job.Type), + fmt.Sprintf("Priority | %d", job.Priority), + fmt.Sprintf("Datacenters | %s", strings.Join(job.Datacenters, ",")), + fmt.Sprintf("Status | %s", job.Status), + fmt.Sprintf("StatusDescription | %s", job.StatusDescription), + } + c.Ui.Output(columnize.SimpleFormat(basic)) + + return 0 +} diff --git a/command/status_test.go b/command/status_test.go new file mode 100644 index 000000000..a50489c55 --- /dev/null +++ b/command/status_test.go @@ -0,0 +1,84 @@ +package command + +import ( + "strings" + "testing" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" +) + +func TestStatusCommand_Implements(t *testing.T) { + var _ cli.Command = &StatusCommand{} +} + +func TestStatusCommand_Run(t *testing.T) { + srv, client, url := testServer(t) + defer srv.Stop() + + ui := new(cli.MockUi) + cmd := &StatusCommand{Meta: Meta{Ui: ui}} + + // Should return blank for no jobs + if code := cmd.Run([]string{"-address=" + url}); code != 0 { + t.Fatalf("expected exit 0, got: %d", code) + } + + // Check for this awkward nil string, since a nil bytes.Buffer + // returns this purposely, and mitchellh/cli has a nil pointer + // if nothing was ever output. + if out := ui.OutputWriter.String(); out != "" { + t.Fatalf("expected empty output, got: %s", out) + } + + // Register two jobs + job1 := api.NewBatchJob("job1", "myjob", 1) + if _, _, err := client.Jobs().Register(job1, nil); err != nil { + t.Fatalf("err: %s", err) + } + job2 := api.NewBatchJob("job2", "myjob", 1) + if _, _, err := client.Jobs().Register(job2, nil); err != nil { + t.Fatalf("err: %s", err) + } + + // Query again and check the result + if code := cmd.Run([]string{"-address=" + url}); code != 0 { + t.Fatalf("expected exit 0, got: %d", code) + } + out := ui.OutputWriter.String() + if !strings.Contains(out, "job1") || !strings.Contains(out, "job2") { + t.Fatalf("expected job1 and job2, got: %s", out) + } + ui.OutputWriter.Reset() + + // Query a single job + if code := cmd.Run([]string{"-address=" + url, "job2"}); code != 0 { + t.Fatalf("expected exit 0, got: %d", code) + } + out = ui.OutputWriter.String() + if strings.Contains(out, "job1") || !strings.Contains(out, "job2") { + t.Fatalf("expected only job2, got: %s", out) + } +} + +func TestStatusCommand_Fails(t *testing.T) { + ui := new(cli.MockUi) + cmd := &StatusCommand{Meta: Meta{Ui: ui}} + + // Fails on misuse + if code := cmd.Run([]string{"some", "bad", "args"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, cmd.Help()) { + t.Fatalf("expected help output, got: %s", out) + } + ui.ErrorWriter.Reset() + + // Fails on connection failure + if code := cmd.Run([]string{"-address=nope"}); code != 1 { + t.Fatalf("expected exit code 1, got: %d", code) + } + if out := ui.ErrorWriter.String(); !strings.Contains(out, "Error querying jobs") { + t.Fatalf("expected failed query error, got: %s", out) + } +} diff --git a/command/util_test.go b/command/util_test.go new file mode 100644 index 000000000..82686fdb0 --- /dev/null +++ b/command/util_test.go @@ -0,0 +1,36 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/testutil" +) + +// seen is used to track which tests we have already +// marked as parallel. Marking twice causes panic. +var seen map[*testing.T]struct{} + +func init() { + seen = make(map[*testing.T]struct{}) +} + +func testServer(t *testing.T) (*testutil.TestServer, *api.Client, string) { + // Always run these tests in parallel. + if _, ok := seen[t]; !ok { + seen[t] = struct{}{} + t.Parallel() + } + + // Make a new test server + srv := testutil.NewTestServer(t, nil) + + // Make a client + clientConf := api.DefaultConfig() + clientConf.Address = "http://" + srv.HTTPAddr + client, err := api.NewClient(clientConf) + if err != nil { + t.Fatalf("err: %s", err) + } + return srv, client, clientConf.Address +} diff --git a/commands.go b/commands.go index 5e47abb1c..05591d40b 100644 --- a/commands.go +++ b/commands.go @@ -34,6 +34,48 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory { }, nil }, + "agent-force-leave": func() (cli.Command, error) { + return &command.AgentForceLeaveCommand{ + Meta: meta, + }, nil + }, + + "agent-info": func() (cli.Command, error) { + return &command.AgentInfoCommand{ + Meta: meta, + }, nil + }, + + "agent-join": func() (cli.Command, error) { + return &command.AgentJoinCommand{ + Meta: meta, + }, nil + }, + + "agent-members": func() (cli.Command, error) { + return &command.AgentMembersCommand{ + Meta: meta, + }, nil + }, + + "node-drain": func() (cli.Command, error) { + return &command.NodeDrainCommand{ + Meta: meta, + }, nil + }, + + "node-status": func() (cli.Command, error) { + return &command.NodeStatusCommand{ + Meta: meta, + }, nil + }, + + "status": func() (cli.Command, error) { + return &command.StatusCommand{ + Meta: meta, + }, nil + }, + "version": func() (cli.Command, error) { ver := Version rel := VersionPrerelease diff --git a/testutil/server.go b/testutil/server.go index 021a8b51a..d9ded40c6 100644 --- a/testutil/server.go +++ b/testutil/server.go @@ -29,7 +29,7 @@ var offset uint64 // TestServerConfig is the main server configuration struct. type TestServerConfig struct { - Bootstrap bool `json:"bootstrap,omitempty"` + NodeName string `json:"name,omitempty"` DataDir string `json:"data_dir,omitempty"` Region string `json:"region,omitempty"` DisableCheckpoint bool `json:"disable_update_check"` @@ -69,8 +69,8 @@ func defaultServerConfig() *TestServerConfig { idx := int(atomic.AddUint64(&offset, 1)) return &TestServerConfig{ + NodeName: fmt.Sprintf("node%d", idx), DisableCheckpoint: true, - Bootstrap: true, LogLevel: "DEBUG", Ports: &PortsConfig{ HTTP: 20000 + idx, @@ -170,7 +170,7 @@ func NewTestServer(t *testing.T, cb ServerConfigCallback) *TestServer { } // Wait for the server to be ready - if nomadConfig.Bootstrap { + if nomadConfig.Server.Enabled && nomadConfig.Server.Bootstrap { server.waitForLeader() } else { server.waitForAPI() @@ -194,7 +194,7 @@ func (s *TestServer) Stop() { // but will likely return before a leader is elected. func (s *TestServer) waitForAPI() { WaitForResult(func() (bool, error) { - resp, err := s.HttpClient.Get(s.url("/v1/jobs?stale")) + resp, err := s.HttpClient.Get(s.url("/v1/agent/self")) if err != nil { return false, err } @@ -226,7 +226,6 @@ func (s *TestServer) waitForLeader() { // Ensure we have a leader and a node registeration if leader := resp.Header.Get("X-Nomad-KnownLeader"); leader != "true" { - fmt.Println(leader) return false, fmt.Errorf("Nomad leader status: %#v", leader) } return true, nil