Merge pull request #42 from hashicorp/f-cli

Base CLI
This commit is contained in:
Ryan Uber 2015-09-14 14:13:10 -07:00
commit 710fcf45e6
25 changed files with 1167 additions and 55 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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"`
}

View file

@ -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)
}

View file

@ -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] <node>
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
}

View file

@ -0,0 +1,11 @@
package command
import (
"testing"
"github.com/mitchellh/cli"
)
func TestAgentForceLeaveCommand_Implements(t *testing.T) {
var _ cli.Command = &AgentForceLeaveCommand{}
}

68
command/agent_info.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

64
command/agent_join.go Normal file
View file

@ -0,0 +1,64 @@
package command
import (
"fmt"
"strings"
)
type AgentJoinCommand struct {
Meta
}
func (c *AgentJoinCommand) Help() string {
helpText := `
Usage: nomad agent-join [options] <addr> [<addr>...]
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
}

View file

@ -0,0 +1,11 @@
package command
import (
"testing"
"github.com/mitchellh/cli"
)
func TestAgentJoinCommand_Implements(t *testing.T) {
var _ cli.Command = &AgentJoinCommand{}
}

121
command/agent_members.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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=<addr>
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)
}

View file

@ -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"},
},
}

77
command/node_drain.go Normal file
View file

@ -0,0 +1,77 @@
package command
import (
"fmt"
"strings"
)
type NodeDrainCommand struct {
Meta
}
func (c *NodeDrainCommand) Help() string {
helpText := `
Usage: nomad node-drain [options] <node>
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
}

View file

@ -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)
}
}

110
command/node_status.go Normal file
View file

@ -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
}

View file

@ -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 != "<nil>" {
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)
}
}

99
command/status.go Normal file
View file

@ -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
}

84
command/status_test.go Normal file
View file

@ -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 != "<nil>" {
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)
}
}

36
command/util_test.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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