diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bfc0d891978..ef23e6585e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## 0.6.1 (Unreleased) IMPROVEMENTS: + * core: Add autocomplete functionality for resources: allocations, + evaluations, jobs, and nodes [GH-2964] * core: `distinct_property` constraint can set the number of allocations that are allowed to share a property value [GH-2942] * core: Lost allocations replaced even if part of failed deployment [GH-2961] diff --git a/command/agent/http.go b/command/agent/http.go index 3891ef37f6cb..aa6880a5835a 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -145,6 +145,8 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/evaluations", s.wrap(s.EvalsRequest)) s.mux.HandleFunc("/v1/evaluation/", s.wrap(s.EvalSpecificRequest)) + s.mux.HandleFunc("/v1/resources/", s.wrap(s.ResourceListRequest)) + s.mux.HandleFunc("/v1/deployments", s.wrap(s.DeploymentsRequest)) s.mux.HandleFunc("/v1/deployment/", s.wrap(s.DeploymentSpecificRequest)) diff --git a/command/agent/resources_endpoint.go b/command/agent/resources_endpoint.go new file mode 100644 index 000000000000..fadc98145e66 --- /dev/null +++ b/command/agent/resources_endpoint.go @@ -0,0 +1,31 @@ +package agent + +import ( + "github.com/hashicorp/nomad/nomad/structs" + "net/http" +) + +// ResourceListRequest accepts a prefix and context and returns a list of matching +// IDs for that context. +func (s *HTTPServer) ResourceListRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + if req.Method == "POST" || req.Method == "PUT" { + return s.resourcesRequest(resp, req) + } + return nil, CodedError(405, ErrInvalidMethod) +} + +func (s *HTTPServer) resourcesRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + args := structs.ResourceListRequest{} + + if err := decodeBody(req, &args); err != nil { + return nil, CodedError(400, err.Error()) + } + + var out structs.ResourceListResponse + if err := s.agent.RPC("Resources.List", &args, &out); err != nil { + return nil, err + } + + setMeta(resp, &out.QueryMeta) + return out, nil +} diff --git a/command/agent/resources_endpoint_test.go b/command/agent/resources_endpoint_test.go new file mode 100644 index 000000000000..c40b39bdb76a --- /dev/null +++ b/command/agent/resources_endpoint_test.go @@ -0,0 +1,304 @@ +package agent + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" + a "github.com/stretchr/testify/assert" +) + +func TestHTTP_ResourcesWithIllegalMethod(t *testing.T) { + assert := a.New(t) + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + req, err := http.NewRequest("DELETE", "/v1/resources", nil) + assert.Nil(err) + respW := httptest.NewRecorder() + + _, err = s.Server.ResourceListRequest(respW, req) + assert.NotNil(err, "HTTP DELETE should not be accepted for this endpoint") + }) +} + +func createJobForTest(jobID string, s *TestAgent, t *testing.T) { + assert := a.New(t) + + job := mock.Job() + job.ID = jobID + job.TaskGroups[0].Count = 1 + + state := s.Agent.server.State() + err := state.UpsertJob(1000, job) + assert.Nil(err) +} + +func TestHTTP_Resources_POST(t *testing.T) { + assert := a.New(t) + + testJob := "aaaaaaaa-e8f7-fd38-c855-ab94ceb89706" + testJobPrefix := "aaaaaaaa-e8f7-fd38" + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + createJobForTest(testJob, s, t) + + data := structs.ResourceListRequest{Prefix: testJobPrefix, Context: "jobs"} + req, err := http.NewRequest("POST", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + assert.Equal(1, len(res.Matches)) + + j := res.Matches["jobs"] + + assert.Equal(1, len(j)) + assert.Equal(j[0], testJob) + + assert.Equal(res.Truncations["job"], false) + assert.NotEqual("0", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} + +func TestHTTP_Resources_PUT(t *testing.T) { + assert := a.New(t) + + testJob := "aaaaaaaa-e8f7-fd38-c855-ab94ceb89706" + testJobPrefix := "aaaaaaaa-e8f7-fd38" + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + createJobForTest(testJob, s, t) + + data := structs.ResourceListRequest{Prefix: testJobPrefix, Context: "jobs"} + req, err := http.NewRequest("PUT", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + assert.Equal(1, len(res.Matches)) + + j := res.Matches["jobs"] + + assert.Equal(1, len(j)) + assert.Equal(j[0], testJob) + + assert.Equal(res.Truncations["job"], false) + assert.NotEqual("0", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} + +func TestHTTP_Resources_MultipleJobs(t *testing.T) { + assert := a.New(t) + + testJobA := "aaaaaaaa-e8f7-fd38-c855-ab94ceb89706" + testJobB := "aaaaaaaa-e8f7-fd38-c855-ab94ceb89707" + testJobC := "bbbbbbbb-e8f7-fd38-c855-ab94ceb89707" + + testJobPrefix := "aaaaaaaa-e8f7-fd38" + + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + createJobForTest(testJobA, s, t) + createJobForTest(testJobB, s, t) + createJobForTest(testJobC, s, t) + + data := structs.ResourceListRequest{Prefix: testJobPrefix, Context: "jobs"} + req, err := http.NewRequest("POST", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + assert.Equal(1, len(res.Matches)) + + j := res.Matches["jobs"] + + assert.Equal(2, len(j)) + assert.Contains(j, testJobA) + assert.Contains(j, testJobB) + assert.NotContains(j, testJobC) + + assert.Equal(res.Truncations["job"], false) + assert.NotEqual("0", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} + +func TestHTTP_ResoucesList_Evaluation(t *testing.T) { + assert := a.New(t) + + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + state := s.Agent.server.State() + eval1 := mock.Eval() + eval2 := mock.Eval() + err := state.UpsertEvals(9000, + []*structs.Evaluation{eval1, eval2}) + assert.Nil(err) + + prefix := eval1.ID[:len(eval1.ID)-2] + data := structs.ResourceListRequest{Prefix: prefix, Context: "evals"} + req, err := http.NewRequest("POST", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + assert.Equal(1, len(res.Matches)) + + j := res.Matches["evals"] + assert.Equal(1, len(j)) + assert.Contains(j, eval1.ID) + assert.NotContains(j, eval2.ID) + + assert.Equal(res.Truncations["evals"], false) + assert.Equal("9000", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} + +func TestHTTP_ResoucesList_Allocations(t *testing.T) { + assert := a.New(t) + + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + state := s.Agent.server.State() + alloc := mock.Alloc() + err := state.UpsertAllocs(7000, []*structs.Allocation{alloc}) + assert.Nil(err) + + prefix := alloc.ID[:len(alloc.ID)-2] + data := structs.ResourceListRequest{Prefix: prefix, Context: "allocs"} + req, err := http.NewRequest("POST", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + assert.Equal(1, len(res.Matches)) + + a := res.Matches["allocs"] + assert.Equal(1, len(a)) + assert.Contains(a, alloc.ID) + + assert.Equal(res.Truncations["allocs"], false) + assert.Equal("7000", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} + +func TestHTTP_ResoucesList_Nodes(t *testing.T) { + assert := a.New(t) + + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + state := s.Agent.server.State() + node := mock.Node() + err := state.UpsertNode(6000, node) + assert.Nil(err) + + prefix := node.ID[:len(node.ID)-2] + data := structs.ResourceListRequest{Prefix: prefix, Context: "nodes"} + req, err := http.NewRequest("POST", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + assert.Equal(1, len(res.Matches)) + + n := res.Matches["nodes"] + assert.Equal(1, len(n)) + assert.Contains(n, node.ID) + + assert.Equal(res.Truncations["nodes"], false) + assert.Equal("6000", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} + +func TestHTTP_Resources_NoJob(t *testing.T) { + assert := a.New(t) + + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + data := structs.ResourceListRequest{Prefix: "12345", Context: "jobs"} + req, err := http.NewRequest("POST", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + assert.Equal(1, len(res.Matches)) + assert.Equal(0, len(res.Matches["jobs"])) + + assert.Equal("0", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} + +func TestHTTP_Resources_NoContext(t *testing.T) { + assert := a.New(t) + + testJobID := "aaaaaaaa-e8f7-fd38-c855-ab94ceb89706" + testJobPrefix := "aaaaaaaa-e8f7-fd38" + t.Parallel() + httpTest(t, nil, func(s *TestAgent) { + createJobForTest(testJobID, s, t) + + state := s.Agent.server.State() + eval1 := mock.Eval() + eval1.ID = testJobID + err := state.UpsertEvals(8000, []*structs.Evaluation{eval1}) + assert.Nil(err) + + data := structs.ResourceListRequest{Prefix: testJobPrefix} + req, err := http.NewRequest("POST", "/v1/resources", encodeReq(data)) + assert.Nil(err) + + respW := httptest.NewRecorder() + + resp, err := s.Server.ResourceListRequest(respW, req) + assert.Nil(err) + + res := resp.(structs.ResourceListResponse) + + matchedJobs := res.Matches["jobs"] + matchedEvals := res.Matches["evals"] + + assert.Equal(1, len(matchedJobs)) + assert.Equal(1, len(matchedEvals)) + + assert.Equal(matchedJobs[0], testJobID) + assert.Equal(matchedEvals[0], eval1.ID) + + assert.Equal("8000", respW.HeaderMap.Get("X-Nomad-Index")) + }) +} diff --git a/nomad/resources_endpoint.go b/nomad/resources_endpoint.go new file mode 100644 index 000000000000..35e576b7908f --- /dev/null +++ b/nomad/resources_endpoint.go @@ -0,0 +1,128 @@ +package nomad + +import ( + "fmt" + "github.com/hashicorp/go-memdb" + "github.com/hashicorp/nomad/nomad/state" + "github.com/hashicorp/nomad/nomad/structs" +) + +// truncateLimit is the maximum number of matches that will be returned for a +// prefix for a specific context +const ( + truncateLimit = 20 +) + +// allContexts are the available contexts which searched to find matches for a +// given prefix +var ( + allContexts = []string{"allocs", "nodes", "jobs", "evals"} +) + +// Resource endpoint is used to lookup matches for a given prefix and context +type Resources struct { + srv *Server +} + +// getMatches extracts matches for an iterator, and returns a list of ids for +// these matches. +func (r *Resources) getMatches(iter memdb.ResultIterator) ([]string, bool) { + var matches []string + + for i := 0; i < truncateLimit; i++ { + raw := iter.Next() + if raw == nil { + break + } + + var id string + switch t := raw.(type) { + case *structs.Job: + id = raw.(*structs.Job).ID + case *structs.Evaluation: + id = raw.(*structs.Evaluation).ID + case *structs.Allocation: + id = raw.(*structs.Allocation).ID + case *structs.Node: + id = raw.(*structs.Node).ID + default: + r.srv.logger.Printf("[ERR] nomad.resources: unexpected type for resources context: %T", t) + continue + } + + matches = append(matches, id) + } + + return matches, iter.Next() != nil +} + +// getResourceIter takes a context and returns a memdb iterator specific to +// that context +func getResourceIter(context, prefix string, ws memdb.WatchSet, state *state.StateStore) (memdb.ResultIterator, error) { + switch context { + case "jobs": + return state.JobsByIDPrefix(ws, prefix) + case "evals": + return state.EvalsByIDPrefix(ws, prefix) + case "allocs": + return state.AllocsByIDPrefix(ws, prefix) + case "nodes": + return state.NodesByIDPrefix(ws, prefix) + default: + return nil, fmt.Errorf("context must be one of %v; got %q", allContexts, context) + } +} + +// List is used to list the resouces registered in the system that matches the +// given prefix. Resources are jobs, evaluations, allocations, and/or nodes. +func (r *Resources) List(args *structs.ResourceListRequest, + reply *structs.ResourceListResponse) error { + reply.Matches = make(map[string][]string) + reply.Truncations = make(map[string]bool) + + // Setup the blocking query + opts := blockingOptions{ + queryMeta: &reply.QueryMeta, + queryOpts: &structs.QueryOptions{}, + run: func(ws memdb.WatchSet, state *state.StateStore) error { + + iters := make(map[string]memdb.ResultIterator) + + contexts := allContexts + if args.Context != "" { + contexts = []string{args.Context} + } + + for _, e := range contexts { + iter, err := getResourceIter(e, args.Prefix, ws, state) + if err != nil { + return err + } + iters[e] = iter + } + + // Return matches for the given prefix + for k, v := range iters { + res, isTrunc := r.getMatches(v) + reply.Matches[k] = res + reply.Truncations[k] = isTrunc + } + + // Set the index for the context. If the context has been specified, it + // will be used as the index of the response. Otherwise, the + // maximum index from all resources will be used. + for _, e := range contexts { + index, err := state.Index(e) + if err != nil { + return err + } + if index > reply.Index { + reply.Index = index + } + } + + r.srv.setQueryMeta(&reply.QueryMeta) + return nil + }} + return r.srv.blockingRPC(&opts) +} diff --git a/nomad/resources_endpoint_test.go b/nomad/resources_endpoint_test.go new file mode 100644 index 000000000000..758b08cfa1ab --- /dev/null +++ b/nomad/resources_endpoint_test.go @@ -0,0 +1,330 @@ +package nomad + +import ( + "github.com/hashicorp/net-rpc-msgpackrpc" + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" + "github.com/stretchr/testify/assert" + "strconv" + "testing" +) + +const jobIndex = 1000 + +func registerAndVerifyJob(s *Server, t *testing.T, prefix string, counter int) string { + job := mock.Job() + + job.ID = prefix + strconv.Itoa(counter) + state := s.fsm.State() + if err := state.UpsertJob(jobIndex, job); err != nil { + t.Fatalf("err: %v", err) + } + + return job.ID +} + +func TestResourcesEndpoint_List(t *testing.T) { + assert := assert.New(t) + prefix := "aaaaaaaa-e8f7-fd38-c855-ab94ceb8970" + + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + jobID := registerAndVerifyJob(s, t, prefix, 0) + + req := &structs.ResourceListRequest{ + Prefix: prefix, + Context: "jobs", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(1, len(resp.Matches["jobs"])) + assert.Equal(jobID, resp.Matches["jobs"][0]) + assert.Equal(uint64(jobIndex), resp.Index) +} + +// truncate should limit results to 20 +func TestResourcesEndpoint_List_Truncate(t *testing.T) { + assert := assert.New(t) + prefix := "aaaaaaaa-e8f7-fd38-c855-ab94ceb8970" + + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + for counter := 0; counter < 25; counter++ { + registerAndVerifyJob(s, t, prefix, counter) + } + + req := &structs.ResourceListRequest{ + Prefix: prefix, + Context: "jobs", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(20, len(resp.Matches["jobs"])) + assert.Equal(resp.Truncations["jobs"], true) + assert.Equal(uint64(jobIndex), resp.Index) +} + +func TestResourcesEndpoint_List_Evals(t *testing.T) { + assert := assert.New(t) + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + eval1 := mock.Eval() + s.fsm.State().UpsertEvals(2000, []*structs.Evaluation{eval1}) + + prefix := eval1.ID[:len(eval1.ID)-2] + + req := &structs.ResourceListRequest{ + Prefix: prefix, + Context: "evals", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(1, len(resp.Matches["evals"])) + assert.Equal(eval1.ID, resp.Matches["evals"][0]) + assert.Equal(resp.Truncations["evals"], false) + + assert.Equal(uint64(2000), resp.Index) +} + +func TestResourcesEndpoint_List_Allocation(t *testing.T) { + assert := assert.New(t) + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + alloc := mock.Alloc() + summary := mock.JobSummary(alloc.JobID) + state := s.fsm.State() + + if err := state.UpsertJobSummary(999, summary); err != nil { + t.Fatalf("err: %v", err) + } + if err := state.UpsertAllocs(90, []*structs.Allocation{alloc}); err != nil { + t.Fatalf("err: %v", err) + } + + prefix := alloc.ID[:len(alloc.ID)-2] + + req := &structs.ResourceListRequest{ + Prefix: prefix, + Context: "allocs", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(1, len(resp.Matches["allocs"])) + assert.Equal(alloc.ID, resp.Matches["allocs"][0]) + assert.Equal(resp.Truncations["allocs"], false) + + assert.Equal(uint64(90), resp.Index) +} + +func TestResourcesEndpoint_List_Node(t *testing.T) { + assert := assert.New(t) + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + state := s.fsm.State() + node := mock.Node() + + if err := state.UpsertNode(100, node); err != nil { + t.Fatalf("err: %v", err) + } + + prefix := node.ID[:len(node.ID)-2] + + req := &structs.ResourceListRequest{ + Prefix: prefix, + Context: "nodes", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(1, len(resp.Matches["nodes"])) + assert.Equal(node.ID, resp.Matches["nodes"][0]) + assert.Equal(false, resp.Truncations["nodes"]) + + assert.Equal(uint64(100), resp.Index) +} + +func TestResourcesEndpoint_List_InvalidContext(t *testing.T) { + assert := assert.New(t) + + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + req := &structs.ResourceListRequest{ + Prefix: "anyPrefix", + Context: "invalid", + } + + var resp structs.ResourceListResponse + err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp) + assert.Equal(err.Error(), "context must be one of [allocs nodes jobs evals]; got \"invalid\"") + + assert.Equal(uint64(0), resp.Index) +} + +func TestResourcesEndpoint_List_NoContext(t *testing.T) { + assert := assert.New(t) + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + state := s.fsm.State() + node := mock.Node() + + if err := state.UpsertNode(100, node); err != nil { + t.Fatalf("err: %v", err) + } + + eval1 := mock.Eval() + eval1.ID = node.ID + if err := state.UpsertEvals(1000, []*structs.Evaluation{eval1}); err != nil { + t.Fatalf("err: %v", err) + } + + prefix := node.ID[:len(node.ID)-2] + + req := &structs.ResourceListRequest{ + Prefix: prefix, + Context: "", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(1, len(resp.Matches["nodes"])) + assert.Equal(1, len(resp.Matches["evals"])) + + assert.Equal(node.ID, resp.Matches["nodes"][0]) + assert.Equal(eval1.ID, resp.Matches["evals"][0]) + + assert.Equal(uint64(1000), resp.Index) +} + +// Tests that the top 20 matches are returned when no prefix is set +func TestResourcesEndpoint_List_NoPrefix(t *testing.T) { + assert := assert.New(t) + + prefix := "aaaaaaaa-e8f7-fd38-c855-ab94ceb8970" + + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + jobID := registerAndVerifyJob(s, t, prefix, 0) + + req := &structs.ResourceListRequest{ + Prefix: "", + Context: "jobs", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(1, len(resp.Matches["jobs"])) + assert.Equal(jobID, resp.Matches["jobs"][0]) + assert.Equal(uint64(jobIndex), resp.Index) +} + +//// Tests that the zero matches are returned when a prefix has no matching +//// results +func TestResourcesEndpoint_List_NoMatches(t *testing.T) { + assert := assert.New(t) + + prefix := "aaaaaaaa-e8f7-fd38-c855-ab94ceb8970" + + t.Parallel() + s := testServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + req := &structs.ResourceListRequest{ + Prefix: prefix, + Context: "jobs", + } + + var resp structs.ResourceListResponse + if err := msgpackrpc.CallWithCodec(codec, "Resources.List", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + + assert.Equal(0, len(resp.Matches["jobs"])) + assert.Equal(uint64(0), resp.Index) +} diff --git a/nomad/server.go b/nomad/server.go index 19d8a6d054b9..a18f697722ea 100644 --- a/nomad/server.go +++ b/nomad/server.go @@ -174,6 +174,7 @@ type endpoints struct { Alloc *Alloc Deployment *Deployment Region *Region + Resources *Resources Periodic *Periodic System *System Operator *Operator @@ -725,6 +726,7 @@ func (s *Server) setupRPC(tlsWrap tlsutil.RegionWrapper) error { s.endpoints.Region = &Region{s} s.endpoints.Status = &Status{s} s.endpoints.System = &System{s} + s.endpoints.Resources = &Resources{s} // Register the handlers s.rpcServer.Register(s.endpoints.Alloc) @@ -738,6 +740,7 @@ func (s *Server) setupRPC(tlsWrap tlsutil.RegionWrapper) error { s.rpcServer.Register(s.endpoints.Region) s.rpcServer.Register(s.endpoints.Status) s.rpcServer.Register(s.endpoints.System) + s.rpcServer.Register(s.endpoints.Resources) list, err := net.ListenTCP("tcp", s.config.RPCAddr) if err != nil { diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 8143e3c9caa4..7f40183c5d24 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -231,6 +231,33 @@ type NodeSpecificRequest struct { QueryOptions } +// ResourceListResponse is used to return matches and information about whether +// the match list is truncated specific to each type of context. +type ResourceListResponse struct { + // Map of context types to resource ids which match a specified prefix + Matches map[string][]string + + // Truncations indicates whether the matches for a particular context have + // been truncated + Truncations map[string]bool + + QueryMeta +} + +// ResourceListRequest is used to parameterize a resources request, and returns a +// subset of information for jobs, allocations, evaluations, and nodes, along +// with whether or not the information returned is truncated. +type ResourceListRequest struct { + // Prefix is what resources are matched to. I.e, if the given prefix were + // "a", potential matches might be "abcd" or "aabb" + Prefix string + + // Context is the resource that can be matched. A context can be a job, node, + // evaluation, allocation, or empty (indicated every context should be + // matched) + Context string +} + // JobRegisterRequest is used for Job.Register endpoint // to register a job as being a schedulable entity. type JobRegisterRequest struct {