Skip to content

Commit

Permalink
API for Eval.Count
Browse files Browse the repository at this point in the history
Add a new `Eval.Count` RPC and associated HTTP API endpoints. This API is
designed to support interactive use in the `nomad eval delete` command to get a
count of evals expected to be deleted before doing so.

The state store operations to do this sort of thing are somewhat expensive, but
it's cheaper than serializing a big list of evals to JSON. Note that although it
seems like this could be done as an extra parameter and response field on
`Eval.List`, having it as its own endpoint avoids having to change the response
body shape and lets us avoid handling the legacy filter params supported by
`Eval.List`.
  • Loading branch information
tgross committed Nov 4, 2022
1 parent 748a99d commit b60e651
Show file tree
Hide file tree
Showing 8 changed files with 365 additions and 0 deletions.
15 changes: 15 additions & 0 deletions api/evaluations.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ func (e *Evaluations) PrefixList(prefix string) ([]*Evaluation, *QueryMeta, erro
return e.List(&QueryOptions{Prefix: prefix})
}

// Count is used to get a count of evaluations.
func (e *Evaluations) Count(q *QueryOptions) (*EvalCountResponse, *QueryMeta, error) {
var resp *EvalCountResponse
qm, err := e.client.query("/v1/evaluations/count", &resp, q)
if err != nil {
return resp, nil, err
}
return resp, qm, nil
}

// Info is used to query a single evaluation by its ID.
func (e *Evaluations) Info(evalID string, q *QueryOptions) (*Evaluation, *QueryMeta, error) {
var resp Evaluation
Expand Down Expand Up @@ -133,6 +143,11 @@ type EvalDeleteRequest struct {
WriteRequest
}

type EvalCountResponse struct {
Count int
QueryMeta
}

// EvalIndexSort is a wrapper to sort evaluations by CreateIndex.
// We reverse the test so that we get the highest index first.
type EvalIndexSort []*Evaluation
Expand Down
19 changes: 19 additions & 0 deletions command/agent/eval_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,22 @@ func (s *HTTPServer) evalQuery(resp http.ResponseWriter, req *http.Request, eval
}
return out.Eval, nil
}

func (s *HTTPServer) EvalsCountRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != http.MethodGet {
return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod)
}

args := structs.EvalCountRequest{}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}

var out structs.EvalCountResponse
if err := s.agent.RPC("Eval.Count", &args, &out); err != nil {
return nil, err
}

setMeta(resp, &out.QueryMeta)
return &out, nil
}
44 changes: 44 additions & 0 deletions command/agent/eval_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/shoenig/test/must"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -372,3 +374,45 @@ func TestHTTP_EvalQueryWithRelated(t *testing.T) {
require.Equal(t, expected, e.RelatedEvals)
})
}

func TestHTTP_EvalCount(t *testing.T) {
ci.Parallel(t)
httpTest(t, nil, func(s *TestAgent) {
// Directly manipulate the state
state := s.Agent.server.State()
eval1 := mock.Eval()
eval2 := mock.Eval()
err := state.UpsertEvals(structs.MsgTypeTestSetup, 1000, []*structs.Evaluation{eval1, eval2})
must.NoError(t, err)

// simple count request
req, err := http.NewRequest("GET", "/v1/evaluations/count", nil)
must.NoError(t, err)
respW := httptest.NewRecorder()
obj, err := s.Server.EvalsCountRequest(respW, req)
must.NoError(t, err)

// check headers and response body
must.NotEq(t, "", respW.Result().Header.Get("X-Nomad-Index"),
must.Sprint("missing index"))
must.Eq(t, "true", respW.Result().Header.Get("X-Nomad-KnownLeader"),
must.Sprint("missing known leader"))
must.NotEq(t, "", respW.Result().Header.Get("X-Nomad-LastContact"),
must.Sprint("missing last contact"))

resp := obj.(*structs.EvalCountResponse)
must.Eq(t, resp.Count, 2)

// filtered count request
v := url.Values{}
v.Add("filter", fmt.Sprintf("JobID==\"%s\"", eval2.JobID))
req, err = http.NewRequest("GET", "/v1/evaluations/count?"+v.Encode(), nil)
must.NoError(t, err)
respW = httptest.NewRecorder()
obj, err = s.Server.EvalsCountRequest(respW, req)
must.NoError(t, err)
resp = obj.(*structs.EvalCountResponse)
must.Eq(t, resp.Count, 1)

})
}
1 change: 1 addition & 0 deletions command/agent/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ func (s HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/allocation/", s.wrap(s.AllocSpecificRequest))

s.mux.HandleFunc("/v1/evaluations", s.wrap(s.EvalsRequest))
s.mux.HandleFunc("/v1/evaluations/count", s.wrap(s.EvalsCountRequest))
s.mux.HandleFunc("/v1/evaluation/", s.wrap(s.EvalSpecificRequest))

s.mux.HandleFunc("/v1/deployments", s.wrap(s.DeploymentsRequest))
Expand Down
99 changes: 99 additions & 0 deletions nomad/eval_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

metrics "github.com/armon/go-metrics"
"github.com/hashicorp/go-bexpr"
log "github.com/hashicorp/go-hclog"
memdb "github.com/hashicorp/go-memdb"
multierror "github.com/hashicorp/go-multierror"
Expand Down Expand Up @@ -611,6 +612,104 @@ func (e *Eval) List(args *structs.EvalListRequest, reply *structs.EvalListRespon
return e.srv.blockingRPC(&opts)
}

// Count is used to get a list of the evaluations in the system
func (e *Eval) Count(args *structs.EvalCountRequest, reply *structs.EvalCountResponse) error {
if done, err := e.srv.forward("Eval.Count", args, args, reply); done {
return err
}
defer metrics.MeasureSince([]string{"nomad", "eval", "count"}, time.Now())
namespace := args.RequestNamespace()

// Check for read-job permissions
aclObj, err := e.srv.ResolveToken(args.AuthToken)
if err != nil {
return err
}
if !aclObj.AllowNsOp(namespace, acl.NamespaceCapabilityReadJob) {
return structs.ErrPermissionDenied
}
allow := aclObj.AllowNsOpFunc(acl.NamespaceCapabilityReadJob)

var filter *bexpr.Evaluator
if args.Filter != "" {
filter, err = bexpr.CreateEvaluator(args.Filter)
if err != nil {
return err
}
}

// Setup the blocking query. This is only superficially like Eval.List,
// because we don't any concerns about pagination, sorting, and legacy
// filter fields.
opts := blockingOptions{
queryOpts: &args.QueryOptions,
queryMeta: &reply.QueryMeta,
run: func(ws memdb.WatchSet, store *state.StateStore) error {
// Scan all the evaluations
var err error
var iter memdb.ResultIterator

// Get the namespaces the user is allowed to access.
allowableNamespaces, err := allowedNSes(aclObj, store, allow)
if err != nil {
return err
}

if prefix := args.QueryOptions.Prefix; prefix != "" {
iter, err = store.EvalsByIDPrefix(ws, namespace, prefix, state.SortDefault)
} else if namespace != structs.AllNamespacesSentinel {
iter, err = store.EvalsByNamespace(ws, namespace)
} else {
iter, err = store.Evals(ws, state.SortDefault)
}
if err != nil {
return err
}

count := 0

iter = memdb.NewFilterIterator(iter, func(raw interface{}) bool {
if raw == nil {
return true
}
eval := raw.(*structs.Evaluation)
if allowableNamespaces != nil && !allowableNamespaces[eval.Namespace] {
return true
}
if filter != nil {
ok, err := filter.Evaluate(eval)
if err != nil {
return true
}
return !ok
}
return false
})

for {
raw := iter.Next()
if raw == nil {
break
}
count++
}

// Use the last index that affected the jobs table
index, err := store.Index("evals")
if err != nil {
return err
}
reply.Index = index
reply.Count = count

// Set the query response
e.srv.setQueryMeta(&reply.QueryMeta)
return nil
}}

return e.srv.blockingRPC(&opts)
}

// Allocations is used to list the allocations for an evaluation
func (e *Eval) Allocations(args *structs.EvalSpecificRequest,
reply *structs.EvalAllocationsResponse) error {
Expand Down
118 changes: 118 additions & 0 deletions nomad/eval_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,124 @@ func TestEvalEndpoint_List_PaginationFiltering(t *testing.T) {
require.Equal(t, tc.expectedNextToken, resp.QueryMeta.NextToken, "unexpected NextToken")
})
}

}

func TestEvalEndpoint_Count(t *testing.T) {
ci.Parallel(t)
s1, _, cleanupS1 := TestACLServer(t, nil)
defer cleanupS1()
codec := rpcClient(t, s1)
index := uint64(100)
testutil.WaitForLeader(t, s1.RPC)
store := s1.fsm.State()

// Create non-default namespace
nondefaultNS := mock.Namespace()
nondefaultNS.Name = "non-default"
err := store.UpsertNamespaces(index, []*structs.Namespace{nondefaultNS})
must.NoError(t, err)

// create a set of evals and field values to filter on.
mocks := []struct {
namespace string
status string
}{
{namespace: structs.DefaultNamespace, status: structs.EvalStatusPending},
{namespace: structs.DefaultNamespace, status: structs.EvalStatusPending},
{namespace: structs.DefaultNamespace, status: structs.EvalStatusPending},
{namespace: nondefaultNS.Name, status: structs.EvalStatusPending},
{namespace: structs.DefaultNamespace, status: structs.EvalStatusComplete},
{namespace: nondefaultNS.Name, status: structs.EvalStatusComplete},
}

evals := []*structs.Evaluation{}
for i, m := range mocks {
eval := mock.Eval()
eval.ID = fmt.Sprintf("%d", i) + uuid.Generate()[1:] // sorted for prefix count tests
eval.Namespace = m.namespace
eval.Status = m.status
evals = append(evals, eval)
}

index++
require.NoError(t, store.UpsertEvals(structs.MsgTypeTestSetup, index, evals))

index++
aclToken := mock.CreatePolicyAndToken(t, store, index, "test-read-any",
mock.NamespacePolicy("*", "read", nil)).SecretID

limitedACLToken := mock.CreatePolicyAndToken(t, store, index, "test-read-limited",
mock.NamespacePolicy("default", "read", nil)).SecretID

cases := []struct {
name string
namespace string
prefix string
filter string
token string
expectedCount int
}{
{
name: "count wildcard namespace with read-any ACL",
namespace: "*",
token: aclToken,
expectedCount: 6,
},
{
name: "count wildcard namespace with limited-read ACL",
namespace: "*",
token: limitedACLToken,
expectedCount: 4,
},
{
name: "count wildcard namespace with prefix",
namespace: "*",
prefix: evals[2].ID[:2],
token: aclToken,
expectedCount: 1,
},
{
name: "count default namespace with filter",
namespace: structs.DefaultNamespace,
filter: "Status == \"pending\"",
token: aclToken,
expectedCount: 3,
},
{
name: "count nondefault namespace with filter",
namespace: "non-default",
filter: "Status == \"complete\"",
token: aclToken,
expectedCount: 1,
},
{
name: "count no results",
namespace: "non-default",
filter: "Status == \"never\"",
token: aclToken,
expectedCount: 0,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := &structs.EvalCountRequest{
QueryOptions: structs.QueryOptions{
Region: "global",
Namespace: tc.namespace,
Prefix: tc.prefix,
Filter: tc.filter,
},
}
req.AuthToken = tc.token
var resp structs.EvalCountResponse
err := msgpackrpc.CallWithCodec(codec, "Eval.Count", req, &resp)
must.NoError(t, err)
must.Eq(t, tc.expectedCount, resp.Count)
})
}

}

func TestEvalEndpoint_Allocations(t *testing.T) {
Expand Down
11 changes: 11 additions & 0 deletions nomad/structs/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,11 @@ func (req *EvalListRequest) ShouldBeFiltered(e *Evaluation) bool {
return false
}

// EvalCountRequest is used to count evaluations
type EvalCountRequest struct {
QueryOptions
}

// PlanRequest is used to submit an allocation plan to the leader
type PlanRequest struct {
Plan *Plan
Expand Down Expand Up @@ -1599,6 +1604,12 @@ type EvalListResponse struct {
QueryMeta
}

// EvalCountResponse is used for a count request
type EvalCountResponse struct {
Count int
QueryMeta
}

// EvalAllocationsResponse is used to return the allocations for an evaluation
type EvalAllocationsResponse struct {
Allocations []*AllocListStub
Expand Down
Loading

0 comments on commit b60e651

Please sign in to comment.