diff --git a/api/nodes.go b/api/nodes.go index 4868fef7c..94fc206ce 100644 --- a/api/nodes.go +++ b/api/nodes.go @@ -4,6 +4,8 @@ import ( "fmt" "sort" "time" + + "github.com/hashicorp/nomad/nomad/structs" ) // Nodes is used to query node-related API endpoints @@ -65,6 +67,32 @@ func (n *Nodes) UpdateDrain(nodeID string, spec *DrainSpec, q *WriteOptions) (*W return wm, nil } +// NodeUpdateEligibilityRequest is used to update the drain specification for a node. +type NodeUpdateEligibilityRequest struct { + // NodeID is the node to update the drain specification for. + NodeID string + Eligibility string +} + +// ToggleEligibility is used to update the scheduling eligibility of the node +func (n *Nodes) ToggleEligibility(nodeID string, eligible bool, q *WriteOptions) (*WriteMeta, error) { + e := structs.NodeSchedulingEligible + if !eligible { + e = structs.NodeSchedulingIneligible + } + + req := &NodeUpdateEligibilityRequest{ + NodeID: nodeID, + Eligibility: e, + } + + wm, err := n.client.write("/v1/node/"+nodeID+"/eligibility", req, nil, q) + if err != nil { + return nil, err + } + return wm, nil +} + // Allocations is used to return the allocations associated with a node. func (n *Nodes) Allocations(nodeID string, q *QueryOptions) ([]*Allocation, *QueryMeta, error) { var resp []*Allocation diff --git a/api/nodes_test.go b/api/nodes_test.go index e2c0a3c78..22d61c401 100644 --- a/api/nodes_test.go +++ b/api/nodes_test.go @@ -212,6 +212,72 @@ func TestNodes_ToggleDrain(t *testing.T) { } } +func TestNodes_ToggleEligibility(t *testing.T) { + t.Parallel() + c, s := makeClient(t, nil, func(c *testutil.TestServerConfig) { + c.DevMode = true + }) + defer s.Stop() + nodes := c.Nodes() + + // Wait for node registration and get the ID + var nodeID string + testutil.WaitForResult(func() (bool, error) { + out, _, err := nodes.List(nil) + if err != nil { + return false, err + } + if n := len(out); n != 1 { + return false, fmt.Errorf("expected 1 node, got: %d", n) + } + nodeID = out[0].ID + return true, nil + }, func(err error) { + t.Fatalf("err: %s", err) + }) + + // Check for eligibility + out, _, err := nodes.Info(nodeID, nil) + if err != nil { + t.Fatalf("err: %s", err) + } + if out.SchedulingEligibility != structs.NodeSchedulingEligible { + t.Fatalf("node should be eligible") + } + + // Toggle it off + wm, err := nodes.ToggleEligibility(nodeID, false, nil) + if err != nil { + t.Fatalf("err: %s", err) + } + assertWriteMeta(t, wm) + + // Check again + out, _, err = nodes.Info(nodeID, nil) + if err != nil { + t.Fatalf("err: %s", err) + } + if out.SchedulingEligibility != structs.NodeSchedulingIneligible { + t.Fatalf("bad eligibility: %v vs %v", out.SchedulingEligibility, structs.NodeSchedulingIneligible) + } + + // Toggle on + wm, err = nodes.ToggleEligibility(nodeID, true, nil) + if err != nil { + t.Fatalf("err: %s", err) + } + assertWriteMeta(t, wm) + + // Check again + out, _, err = nodes.Info(nodeID, nil) + if err != nil { + t.Fatalf("err: %s", err) + } + if out.SchedulingEligibility != structs.NodeSchedulingEligible { + t.Fatalf("bad eligibility: %v vs %v", out.SchedulingEligibility, structs.NodeSchedulingEligible) + } +} + func TestNodes_Allocations(t *testing.T) { t.Parallel() c, s := makeClient(t, nil, nil) diff --git a/command/agent/node_endpoint.go b/command/agent/node_endpoint.go index 1a937447e..a86df751c 100644 --- a/command/agent/node_endpoint.go +++ b/command/agent/node_endpoint.go @@ -44,6 +44,9 @@ func (s *HTTPServer) NodeSpecificRequest(resp http.ResponseWriter, req *http.Req case strings.HasSuffix(path, "/drain"): nodeName := strings.TrimSuffix(path, "/drain") return s.nodeToggleDrain(resp, req, nodeName) + case strings.HasSuffix(path, "/eligibility"): + nodeName := strings.TrimSuffix(path, "/eligibility") + return s.nodeToggleEligibility(resp, req, nodeName) case strings.HasSuffix(path, "/purge"): nodeName := strings.TrimSuffix(path, "/purge") return s.nodePurge(resp, req, nodeName) @@ -149,6 +152,26 @@ func (s *HTTPServer) nodeToggleDrain(resp http.ResponseWriter, req *http.Request return out, nil } +func (s *HTTPServer) nodeToggleEligibility(resp http.ResponseWriter, req *http.Request, + nodeID string) (interface{}, error) { + if req.Method != "PUT" && req.Method != "POST" { + return nil, CodedError(405, ErrInvalidMethod) + } + + var drainRequest structs.NodeUpdateEligibilityRequest + if err := decodeBody(req, &drainRequest); err != nil { + return nil, CodedError(400, err.Error()) + } + s.parseWriteRequest(req, &drainRequest.WriteRequest) + + var out structs.GenericResponse + if err := s.agent.RPC("Node.UpdateEligibility", &drainRequest, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + return nil, nil +} + func (s *HTTPServer) nodeQuery(resp http.ResponseWriter, req *http.Request, nodeID string) (interface{}, error) { if req.Method != "GET" { diff --git a/command/agent/node_endpoint_test.go b/command/agent/node_endpoint_test.go index ac1bd00b7..e208f59b7 100644 --- a/command/agent/node_endpoint_test.go +++ b/command/agent/node_endpoint_test.go @@ -302,6 +302,57 @@ func TestHTTP_NodeDrain(t *testing.T) { }) } +func TestHTTP_NodeEligble(t *testing.T) { + t.Parallel() + require := require.New(t) + httpTest(t, nil, func(s *TestAgent) { + // Create the node + node := mock.Node() + args := structs.NodeRegisterRequest{ + Node: node, + WriteRequest: structs.WriteRequest{Region: "global"}, + } + var resp structs.NodeUpdateResponse + require.Nil(s.Agent.RPC("Node.Register", &args, &resp)) + + drainReq := api.NodeUpdateEligibilityRequest{ + NodeID: node.ID, + Eligibility: structs.NodeSchedulingIneligible, + } + + // Make the HTTP request + buf := encodeReq(drainReq) + req, err := http.NewRequest("POST", "/v1/node/"+node.ID+"/eligibility", buf) + require.Nil(err) + respW := httptest.NewRecorder() + + // Make the request + _, err = s.Server.NodeSpecificRequest(respW, req) + require.Nil(err) + + // Check for the index + require.NotZero(respW.HeaderMap.Get("X-Nomad-Index")) + + // Check that the node has been updated + state := s.Agent.server.State() + out, err := state.NodeByID(nil, node.ID) + require.Nil(err) + require.Equal(structs.NodeSchedulingIneligible, out.SchedulingEligibility) + + // Make the HTTP request to set something invalid + drainReq.Eligibility = "foo" + buf = encodeReq(drainReq) + req, err = http.NewRequest("POST", "/v1/node/"+node.ID+"/eligibility", buf) + require.Nil(err) + respW = httptest.NewRecorder() + + // Make the request + _, err = s.Server.NodeSpecificRequest(respW, req) + require.NotNil(err) + require.Contains(err.Error(), "invalid") + }) +} + func TestHTTP_NodePurge(t *testing.T) { t.Parallel() httpTest(t, nil, func(s *TestAgent) { diff --git a/nomad/node_endpoint.go b/nomad/node_endpoint.go index f46de1661..5cf5aa587 100644 --- a/nomad/node_endpoint.go +++ b/nomad/node_endpoint.go @@ -494,6 +494,12 @@ func (n *Node) UpdateEligibility(args *structs.NodeUpdateEligibilityRequest, return fmt.Errorf("can not set node's scheduling eligibility to eligible while it is draining") } + switch args.Eligibility { + case structs.NodeSchedulingEligible, structs.NodeSchedulingIneligible: + default: + return fmt.Errorf("invalid scheduling eligibility %q", args.Eligibility) + } + // Commit this update via Raft outErr, index, err := n.srv.raftApply(structs.NodeUpdateEligibilityRequestType, args) if err != nil {