Add `go-bexpr` filters to evals and deployment list endpoints (#12034)

This commit is contained in:
Luiz Aoqui 2022-02-16 11:40:30 -05:00 committed by GitHub
parent c30b4617aa
commit 110dbeeb9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 509 additions and 33 deletions

3
.changelog/12034.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
api: filter values of evaluation and deployment list api endpoints
```

View File

@ -65,6 +65,10 @@ type QueryOptions struct {
// AuthToken is the secret ID of an ACL token // AuthToken is the secret ID of an ACL token
AuthToken string AuthToken string
// Filter specifies the go-bexpr filter expression to be used for
// filtering the data prior to returning a response
Filter string
// PerPage is the number of entries to be returned in queries that support // PerPage is the number of entries to be returned in queries that support
// paginated lists. // paginated lists.
PerPage int32 PerPage int32
@ -586,6 +590,9 @@ func (r *request) setQueryOptions(q *QueryOptions) {
if q.Prefix != "" { if q.Prefix != "" {
r.params.Set("prefix", q.Prefix) r.params.Set("prefix", q.Prefix)
} }
if q.Filter != "" {
r.params.Set("filter", q.Filter)
}
if q.PerPage != 0 { if q.PerPage != 0 {
r.params.Set("per_page", fmt.Sprint(q.PerPage)) r.params.Set("per_page", fmt.Sprint(q.PerPage))
} }

View File

@ -77,6 +77,14 @@ func TestEvaluations_List(t *testing.T) {
if len(result) != 1 { if len(result) != 1 {
t.Fatalf("expected no evals after last one but got %v", result[0]) t.Fatalf("expected no evals after last one but got %v", result[0])
} }
// Query evaluations using a filter.
results, _, err = e.List(&QueryOptions{
Filter: `TriggeredBy == "job-register"`,
})
if len(result) != 1 {
t.Fatalf("expected 1 eval, got %d", len(result))
}
} }
func TestEvaluations_PrefixList(t *testing.T) { func TestEvaluations_PrefixList(t *testing.T) {

View File

@ -537,6 +537,9 @@ func (s *HTTPServer) wrap(handler func(resp http.ResponseWriter, req *http.Reque
} else if strings.HasSuffix(errMsg, structs.ErrJobRegistrationDisabled.Error()) { } else if strings.HasSuffix(errMsg, structs.ErrJobRegistrationDisabled.Error()) {
errMsg = structs.ErrJobRegistrationDisabled.Error() errMsg = structs.ErrJobRegistrationDisabled.Error()
code = 403 code = 403
} else if strings.HasSuffix(errMsg, structs.ErrIncompatibleFiltering.Error()) {
errMsg = structs.ErrIncompatibleFiltering.Error()
code = 400
} }
} }
@ -784,6 +787,7 @@ func (s *HTTPServer) parse(resp http.ResponseWriter, req *http.Request, r *strin
parsePrefix(req, b) parsePrefix(req, b)
parseNamespace(req, &b.Namespace) parseNamespace(req, &b.Namespace)
parsePagination(req, b) parsePagination(req, b)
parseFilter(req, b)
return parseWait(resp, req, b) return parseWait(resp, req, b)
} }
@ -801,6 +805,14 @@ func parsePagination(req *http.Request, b *structs.QueryOptions) {
b.NextToken = query.Get("next_token") b.NextToken = query.Get("next_token")
} }
// parseFilter parses the filter query parameter for QueryOptions
func parseFilter(req *http.Request, b *structs.QueryOptions) {
query := req.URL.Query()
if filter := query.Get("filter"); filter != "" {
b.Filter = filter
}
}
// parseWriteRequest is a convenience method for endpoints that need to parse a // parseWriteRequest is a convenience method for endpoints that need to parse a
// write request. // write request.
func (s *HTTPServer) parseWriteRequest(req *http.Request, w *structs.WriteRequest) { func (s *HTTPServer) parseWriteRequest(req *http.Request, w *structs.WriteRequest) {

View File

@ -30,6 +30,9 @@ List Options:
-json -json
Output the deployments in a JSON format. Output the deployments in a JSON format.
-filter
Specifies an expression used to filter query results.
-t -t
Format and display the deployments using a Go template. Format and display the deployments using a Go template.
@ -43,6 +46,7 @@ func (c *DeploymentListCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{ complete.Flags{
"-json": complete.PredictNothing, "-json": complete.PredictNothing,
"-filter": complete.PredictAnything,
"-t": complete.PredictAnything, "-t": complete.PredictAnything,
"-verbose": complete.PredictNothing, "-verbose": complete.PredictNothing,
}) })
@ -60,12 +64,13 @@ func (c *DeploymentListCommand) Name() string { return "deployment list" }
func (c *DeploymentListCommand) Run(args []string) int { func (c *DeploymentListCommand) Run(args []string) int {
var json, verbose bool var json, verbose bool
var tmpl string var filter, tmpl string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient) flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) } flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&verbose, "verbose", false, "") flags.BoolVar(&verbose, "verbose", false, "")
flags.BoolVar(&json, "json", false, "") flags.BoolVar(&json, "json", false, "")
flags.StringVar(&filter, "filter", "", "")
flags.StringVar(&tmpl, "t", "", "") flags.StringVar(&tmpl, "t", "", "")
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
@ -93,7 +98,10 @@ func (c *DeploymentListCommand) Run(args []string) int {
return 1 return 1
} }
deploys, _, err := client.Deployments().List(nil) opts := &api.QueryOptions{
Filter: filter,
}
deploys, _, err := client.Deployments().List(opts)
if err != nil { if err != nil {
c.Ui.Error(fmt.Sprintf("Error retrieving deployments: %s", err)) c.Ui.Error(fmt.Sprintf("Error retrieving deployments: %s", err))
return 1 return 1

View File

@ -35,6 +35,9 @@ Eval List Options:
-page-token -page-token
Where to start pagination. Where to start pagination.
-filter
Specifies an expression used to filter query results.
-job -job
Only show evaluations for this job ID. Only show evaluations for this job ID.
@ -61,6 +64,7 @@ func (c *EvalListCommand) AutocompleteFlags() complete.Flags {
"-json": complete.PredictNothing, "-json": complete.PredictNothing,
"-t": complete.PredictAnything, "-t": complete.PredictAnything,
"-verbose": complete.PredictNothing, "-verbose": complete.PredictNothing,
"-filter": complete.PredictAnything,
"-job": complete.PredictAnything, "-job": complete.PredictAnything,
"-status": complete.PredictAnything, "-status": complete.PredictAnything,
"-per-page": complete.PredictAnything, "-per-page": complete.PredictAnything,
@ -88,7 +92,7 @@ func (c *EvalListCommand) Name() string { return "eval list" }
func (c *EvalListCommand) Run(args []string) int { func (c *EvalListCommand) Run(args []string) int {
var monitor, verbose, json bool var monitor, verbose, json bool
var perPage int var perPage int
var tmpl, pageToken, filterJobID, filterStatus string var tmpl, pageToken, filter, filterJobID, filterStatus string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient) flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) } flags.Usage = func() { c.Ui.Output(c.Help()) }
@ -98,6 +102,7 @@ func (c *EvalListCommand) Run(args []string) int {
flags.StringVar(&tmpl, "t", "", "") flags.StringVar(&tmpl, "t", "", "")
flags.IntVar(&perPage, "per-page", 0, "") flags.IntVar(&perPage, "per-page", 0, "")
flags.StringVar(&pageToken, "page-token", "", "") flags.StringVar(&pageToken, "page-token", "", "")
flags.StringVar(&filter, "filter", "", "")
flags.StringVar(&filterJobID, "job", "", "") flags.StringVar(&filterJobID, "job", "", "")
flags.StringVar(&filterStatus, "status", "", "") flags.StringVar(&filterStatus, "status", "", "")
@ -120,6 +125,7 @@ func (c *EvalListCommand) Run(args []string) int {
} }
opts := &api.QueryOptions{ opts := &api.QueryOptions{
Filter: filter,
PerPage: int32(perPage), PerPage: int32(perPage),
NextToken: pageToken, NextToken: pageToken,
Params: map[string]string{}, Params: map[string]string{},

2
go.mod
View File

@ -48,6 +48,7 @@ require (
github.com/hashicorp/consul/api v1.9.1 github.com/hashicorp/consul/api v1.9.1
github.com/hashicorp/consul/sdk v0.8.0 github.com/hashicorp/consul/sdk v0.8.0
github.com/hashicorp/cronexpr v1.1.1 github.com/hashicorp/cronexpr v1.1.1
github.com/hashicorp/go-bexpr v0.1.11
github.com/hashicorp/go-checkpoint v0.0.0-20171009173528-1545e56e46de github.com/hashicorp/go-checkpoint v0.0.0-20171009173528-1545e56e46de
github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-cleanhttp v0.5.2
github.com/hashicorp/go-connlimit v0.3.0 github.com/hashicorp/go-connlimit v0.3.0
@ -209,6 +210,7 @@ require (
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/pointerstructure v1.2.1 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
github.com/morikuni/aec v1.0.0 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/mrunalp/fileutils v0.5.0 // indirect github.com/mrunalp/fileutils v0.5.0 // indirect

5
go.sum
View File

@ -667,6 +667,8 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-bexpr v0.1.2/go.mod h1:ANbpTX1oAql27TZkKVeW8p1w8NTdnyzPe/0qqPCKohU= github.com/hashicorp/go-bexpr v0.1.2/go.mod h1:ANbpTX1oAql27TZkKVeW8p1w8NTdnyzPe/0qqPCKohU=
github.com/hashicorp/go-bexpr v0.1.11 h1:6DqdA/KBjurGby9yTY0bmkathya0lfwF2SeuubCI7dY=
github.com/hashicorp/go-bexpr v0.1.11/go.mod h1:f03lAo0duBlDIUMGCuad8oLcgejw4m7U+N8T+6Kz1AE=
github.com/hashicorp/go-checkpoint v0.0.0-20171009173528-1545e56e46de h1:XDCSythtg8aWSRSO29uwhgh7b127fWr+m5SemqjSUL8= github.com/hashicorp/go-checkpoint v0.0.0-20171009173528-1545e56e46de h1:XDCSythtg8aWSRSO29uwhgh7b127fWr+m5SemqjSUL8=
github.com/hashicorp/go-checkpoint v0.0.0-20171009173528-1545e56e46de/go.mod h1:xIwEieBHERyEvaeKF/TcHh1Hu+lxPM+n2vT1+g9I4m4= github.com/hashicorp/go-checkpoint v0.0.0-20171009173528-1545e56e46de/go.mod h1:xIwEieBHERyEvaeKF/TcHh1Hu+lxPM+n2vT1+g9I4m4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
@ -938,9 +940,12 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw=
github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=

View File

@ -2,6 +2,7 @@ package nomad
import ( import (
"fmt" "fmt"
"net/http"
"time" "time"
metrics "github.com/armon/go-metrics" metrics "github.com/armon/go-metrics"
@ -421,13 +422,22 @@ func (d *Deployment) List(args *structs.DeploymentListRequest, reply *structs.De
} }
var deploys []*structs.Deployment var deploys []*structs.Deployment
paginator := state.NewPaginator(iter, args.QueryOptions, paginator, err := state.NewPaginator(iter, args.QueryOptions,
func(raw interface{}) { func(raw interface{}) {
deploy := raw.(*structs.Deployment) deploy := raw.(*structs.Deployment)
deploys = append(deploys, deploy) deploys = append(deploys, deploy)
}) })
if err != nil {
return structs.NewErrRPCCodedf(
http.StatusBadRequest, "failed to create result paginator: %v", err)
}
nextToken, err := paginator.Page()
if err != nil {
return structs.NewErrRPCCodedf(
http.StatusBadRequest, "failed to read result page: %v", err)
}
nextToken := paginator.Page()
reply.QueryMeta.NextToken = nextToken reply.QueryMeta.NextToken = nextToken
reply.Deployments = deploys reply.Deployments = deploys

View File

@ -1288,17 +1288,19 @@ func TestDeploymentEndpoint_List_Pagination(t *testing.T) {
} }
aclToken := mock.CreatePolicyAndToken(t, state, 1100, "test-valid-read", aclToken := mock.CreatePolicyAndToken(t, state, 1100, "test-valid-read",
mock.NamespacePolicy(structs.DefaultNamespace, "read", nil)). mock.NamespacePolicy("*", "read", nil)).
SecretID SecretID
cases := []struct { cases := []struct {
name string name string
namespace string namespace string
prefix string prefix string
filter string
nextToken string nextToken string
pageSize int32 pageSize int32
expectedNextToken string expectedNextToken string
expectedIDs []string expectedIDs []string
expectedError string
}{ }{
{ {
name: "test01 size-2 page-1 default NS", name: "test01 size-2 page-1 default NS",
@ -1341,12 +1343,57 @@ func TestDeploymentEndpoint_List_Pagination(t *testing.T) {
}, },
}, },
{ {
name: "test5 no valid results with filters and prefix", name: "test05 no valid results with filters and prefix",
prefix: "cccc", prefix: "cccc",
pageSize: 2, pageSize: 2,
nextToken: "", nextToken: "",
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{
name: "test06 go-bexpr filter",
namespace: "*",
filter: `ID matches "^a+[123]"`,
expectedIDs: []string{
"aaaa1111-3350-4b4b-d185-0e1992ed43e9",
"aaaaaa22-3350-4b4b-d185-0e1992ed43e9",
"aaaaaa33-3350-4b4b-d185-0e1992ed43e9",
},
},
{
name: "test07 go-bexpr filter with pagination",
namespace: "*",
filter: `ID matches "^a+[123]"`,
pageSize: 2,
expectedNextToken: "aaaaaa33-3350-4b4b-d185-0e1992ed43e9",
expectedIDs: []string{
"aaaa1111-3350-4b4b-d185-0e1992ed43e9",
"aaaaaa22-3350-4b4b-d185-0e1992ed43e9",
},
},
{
name: "test08 go-bexpr filter in namespace",
namespace: "non-default",
filter: `Status == "cancelled"`,
expectedIDs: []string{
"aaaaaa33-3350-4b4b-d185-0e1992ed43e9",
},
},
{
name: "test09 go-bexpr wrong namespace",
namespace: "default",
filter: `Namespace == "non-default"`,
expectedIDs: []string{},
},
{
name: "test10 go-bexpr invalid expression",
filter: `NotValid`,
expectedError: "failed to read filter expression",
},
{
name: "test11 go-bexpr invalid field",
filter: `InvalidField == "value"`,
expectedError: "error finding value in datum",
},
} }
for _, tc := range cases { for _, tc := range cases {
@ -1357,13 +1404,22 @@ func TestDeploymentEndpoint_List_Pagination(t *testing.T) {
Region: "global", Region: "global",
Namespace: tc.namespace, Namespace: tc.namespace,
Prefix: tc.prefix, Prefix: tc.prefix,
Filter: tc.filter,
PerPage: tc.pageSize, PerPage: tc.pageSize,
NextToken: tc.nextToken, NextToken: tc.nextToken,
}, },
} }
req.AuthToken = aclToken req.AuthToken = aclToken
var resp structs.DeploymentListResponse var resp structs.DeploymentListResponse
require.NoError(t, msgpackrpc.CallWithCodec(codec, "Deployment.List", req, &resp)) err := msgpackrpc.CallWithCodec(codec, "Deployment.List", req, &resp)
if tc.expectedError == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedError)
return
}
gotIDs := []string{} gotIDs := []string{}
for _, deployment := range resp.Deployments { for _, deployment := range resp.Deployments {
gotIDs = append(gotIDs, deployment.ID) gotIDs = append(gotIDs, deployment.ID)

View File

@ -2,6 +2,7 @@ package nomad
import ( import (
"fmt" "fmt"
"net/http"
"time" "time"
metrics "github.com/armon/go-metrics" metrics "github.com/armon/go-metrics"
@ -397,6 +398,14 @@ func (e *Eval) List(args *structs.EvalListRequest, reply *structs.EvalListRespon
return structs.ErrPermissionDenied return structs.ErrPermissionDenied
} }
if args.Filter != "" {
// Check for incompatible filtering.
hasLegacyFilter := args.FilterJobID != "" || args.FilterEvalStatus != ""
if hasLegacyFilter {
return structs.ErrIncompatibleFiltering
}
}
// Setup the blocking query // Setup the blocking query
opts := blockingOptions{ opts := blockingOptions{
queryOpts: &args.QueryOptions, queryOpts: &args.QueryOptions,
@ -425,13 +434,22 @@ func (e *Eval) List(args *structs.EvalListRequest, reply *structs.EvalListRespon
}) })
var evals []*structs.Evaluation var evals []*structs.Evaluation
paginator := state.NewPaginator(iter, args.QueryOptions, paginator, err := state.NewPaginator(iter, args.QueryOptions,
func(raw interface{}) { func(raw interface{}) {
eval := raw.(*structs.Evaluation) eval := raw.(*structs.Evaluation)
evals = append(evals, eval) evals = append(evals, eval)
}) })
if err != nil {
return structs.NewErrRPCCodedf(
http.StatusBadRequest, "failed to create result paginator: %v", err)
}
nextToken, err := paginator.Page()
if err != nil {
return structs.NewErrRPCCodedf(
http.StatusBadRequest, "failed to read result page: %v", err)
}
nextToken := paginator.Page()
reply.QueryMeta.NextToken = nextToken reply.QueryMeta.NextToken = nextToken
reply.Evaluations = evals reply.Evaluations = evals

View File

@ -1050,7 +1050,7 @@ func TestEvalEndpoint_List_PaginationFiltering(t *testing.T) {
} }
aclToken := mock.CreatePolicyAndToken(t, state, 1100, "test-valid-read", aclToken := mock.CreatePolicyAndToken(t, state, 1100, "test-valid-read",
mock.NamespacePolicy(structs.DefaultNamespace, "read", nil)). mock.NamespacePolicy("*", "read", nil)).
SecretID SecretID
cases := []struct { cases := []struct {
@ -1060,9 +1060,11 @@ func TestEvalEndpoint_List_PaginationFiltering(t *testing.T) {
nextToken string nextToken string
filterJobID string filterJobID string
filterStatus string filterStatus string
filter string
pageSize int32 pageSize int32
expectedNextToken string expectedNextToken string
expectedIDs []string expectedIDs []string
expectedError string
}{ }{
{ {
name: "test01 size-2 page-1 default NS", name: "test01 size-2 page-1 default NS",
@ -1194,6 +1196,52 @@ func TestEvalEndpoint_List_PaginationFiltering(t *testing.T) {
nextToken: "aaaaaa11-3350-4b4b-d185-0e1992ed43e9", nextToken: "aaaaaa11-3350-4b4b-d185-0e1992ed43e9",
expectedIDs: []string{}, expectedIDs: []string{},
}, },
{
name: "test14 go-bexpr filter",
filter: `Status == "blocked"`,
nextToken: "",
expectedIDs: []string{"aaaaaaaa-3350-4b4b-d185-0e1992ed43e9"},
},
{
name: "test15 go-bexpr filter with pagination",
filter: `JobID == "example"`,
pageSize: 2,
expectedNextToken: "aaaaaaaa-3350-4b4b-d185-0e1992ed43e9",
expectedIDs: []string{
"aaaa1111-3350-4b4b-d185-0e1992ed43e9",
"aaaaaa22-3350-4b4b-d185-0e1992ed43e9",
},
},
{
name: "test16 go-bexpr filter namespace",
namespace: "non-default",
filter: `ID contains "aaa"`,
expectedIDs: []string{
"aaaaaa33-3350-4b4b-d185-0e1992ed43e9",
},
},
{
name: "test17 go-bexpr wrong namespace",
namespace: "default",
filter: `Namespace == "non-default"`,
expectedIDs: []string{},
},
{
name: "test18 incompatible filtering",
filter: `JobID == "example"`,
filterStatus: "complete",
expectedError: structs.ErrIncompatibleFiltering.Error(),
},
{
name: "test19 go-bexpr invalid expression",
filter: `NotValid`,
expectedError: "failed to read filter expression",
},
{
name: "test20 go-bexpr invalid field",
filter: `InvalidField == "value"`,
expectedError: "error finding value in datum",
},
} }
for _, tc := range cases { for _, tc := range cases {
@ -1208,11 +1256,20 @@ func TestEvalEndpoint_List_PaginationFiltering(t *testing.T) {
Prefix: tc.prefix, Prefix: tc.prefix,
PerPage: tc.pageSize, PerPage: tc.pageSize,
NextToken: tc.nextToken, NextToken: tc.nextToken,
Filter: tc.filter,
}, },
} }
req.AuthToken = aclToken req.AuthToken = aclToken
var resp structs.EvalListResponse var resp structs.EvalListResponse
require.NoError(t, msgpackrpc.CallWithCodec(codec, "Eval.List", req, &resp)) err := msgpackrpc.CallWithCodec(codec, "Eval.List", req, &resp)
if tc.expectedError == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedError)
return
}
gotIDs := []string{} gotIDs := []string{}
for _, eval := range resp.Evaluations { for _, eval := range resp.Evaluations {
gotIDs = append(gotIDs, eval.ID) gotIDs = append(gotIDs, eval.ID)

228
nomad/state/filter_test.go Normal file
View File

@ -0,0 +1,228 @@
package state
import (
"testing"
"time"
"github.com/hashicorp/go-bexpr"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/structs"
)
func BenchmarkEvalListFilter(b *testing.B) {
const evalCount = 100_000
b.Run("filter with index", func(b *testing.B) {
state := setupPopulatedState(b, evalCount)
b.ResetTimer()
for i := 0; i < b.N; i++ {
iter, _ := state.EvalsByNamespace(nil, structs.DefaultNamespace)
var lastSeen string
var countSeen int
for {
raw := iter.Next()
if raw == nil {
break
}
eval := raw.(*structs.Evaluation)
lastSeen = eval.ID
countSeen++
}
if countSeen < evalCount/2 {
b.Fatalf("failed: %d evals seen, lastSeen=%s", countSeen, lastSeen)
}
}
})
b.Run("filter with go-bexpr", func(b *testing.B) {
state := setupPopulatedState(b, evalCount)
evaluator, err := bexpr.CreateEvaluator(`Namespace == "default"`)
if err != nil {
b.Fatalf("failed: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
iter, _ := state.Evals(nil, false)
var lastSeen string
var countSeen int
for {
raw := iter.Next()
if raw == nil {
break
}
match, err := evaluator.Evaluate(raw)
if !match || err != nil {
continue
}
eval := raw.(*structs.Evaluation)
lastSeen = eval.ID
countSeen++
}
if countSeen < evalCount/2 {
b.Fatalf("failed: %d evals seen, lastSeen=%s", countSeen, lastSeen)
}
}
})
b.Run("paginated filter with index", func(b *testing.B) {
state := setupPopulatedState(b, evalCount)
opts := structs.QueryOptions{
PerPage: 100,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
iter, _ := state.EvalsByNamespace(nil, structs.DefaultNamespace)
var evals []*structs.Evaluation
paginator, err := NewPaginator(iter, opts, func(raw interface{}) {
eval := raw.(*structs.Evaluation)
evals = append(evals, eval)
})
if err != nil {
b.Fatalf("failed: %v", err)
}
paginator.Page()
}
})
b.Run("paginated filter with go-bexpr", func(b *testing.B) {
state := setupPopulatedState(b, evalCount)
opts := structs.QueryOptions{
PerPage: 100,
Filter: `Namespace == "default"`,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
iter, _ := state.Evals(nil, false)
var evals []*structs.Evaluation
paginator, err := NewPaginator(iter, opts, func(raw interface{}) {
eval := raw.(*structs.Evaluation)
evals = append(evals, eval)
})
if err != nil {
b.Fatalf("failed: %v", err)
}
paginator.Page()
}
})
b.Run("paginated filter with index last page", func(b *testing.B) {
state := setupPopulatedState(b, evalCount)
// Find the last eval ID.
iter, _ := state.Evals(nil, false)
var lastSeen string
for {
raw := iter.Next()
if raw == nil {
break
}
eval := raw.(*structs.Evaluation)
lastSeen = eval.ID
}
opts := structs.QueryOptions{
PerPage: 100,
NextToken: lastSeen,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
iter, _ := state.EvalsByNamespace(nil, structs.DefaultNamespace)
var evals []*structs.Evaluation
paginator, err := NewPaginator(iter, opts, func(raw interface{}) {
eval := raw.(*structs.Evaluation)
evals = append(evals, eval)
})
if err != nil {
b.Fatalf("failed: %v", err)
}
paginator.Page()
}
})
b.Run("paginated filter with go-bexpr last page", func(b *testing.B) {
state := setupPopulatedState(b, evalCount)
// Find the last eval ID.
iter, _ := state.Evals(nil, false)
var lastSeen string
for {
raw := iter.Next()
if raw == nil {
break
}
eval := raw.(*structs.Evaluation)
lastSeen = eval.ID
}
opts := structs.QueryOptions{
PerPage: 100,
NextToken: lastSeen,
Filter: `Namespace == "default"`,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
iter, _ := state.Evals(nil, false)
var evals []*structs.Evaluation
paginator, err := NewPaginator(iter, opts, func(raw interface{}) {
eval := raw.(*structs.Evaluation)
evals = append(evals, eval)
})
if err != nil {
b.Fatalf("failed: %v", err)
}
paginator.Page()
}
})
}
// -----------------
// BENCHMARK HELPER FUNCTIONS
func setupPopulatedState(b *testing.B, evalCount int) *StateStore {
evals := generateEvals(evalCount)
index := uint64(0)
var err error
state := TestStateStore(b)
for _, eval := range evals {
index++
err = state.UpsertEvals(
structs.MsgTypeTestSetup, index, []*structs.Evaluation{eval})
}
if err != nil {
b.Fatalf("failed: %v", err)
}
return state
}
func generateEvals(count int) []*structs.Evaluation {
evals := []*structs.Evaluation{}
ns := structs.DefaultNamespace
for i := 0; i < count; i++ {
if i > count/2 {
ns = "other"
}
evals = append(evals, generateEval(i, ns))
}
return evals
}
func generateEval(i int, ns string) *structs.Evaluation {
now := time.Now().UTC().UnixNano()
return &structs.Evaluation{
ID: uuid.Generate(),
Namespace: ns,
Priority: 50,
Type: structs.JobTypeService,
JobID: uuid.Generate(),
Status: structs.EvalStatusPending,
CreateTime: now,
ModifyTime: now,
}
}

View File

@ -1,19 +1,33 @@
package state package state
import ( import (
memdb "github.com/hashicorp/go-memdb" "fmt"
"github.com/hashicorp/go-bexpr"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
) )
// Iterator is the interface that must be implemented to use the Paginator.
type Iterator interface {
// Next returns the next element to be considered for pagination.
// The page will end if nil is returned.
Next() interface{}
}
// Paginator is an iterator over a memdb.ResultIterator that returns // Paginator is an iterator over a memdb.ResultIterator that returns
// only the expected number of pages. // only the expected number of pages.
type Paginator struct { type Paginator struct {
iter memdb.ResultIterator iter Iterator
perPage int32 perPage int32
itemCount int32 itemCount int32
seekingToken string seekingToken string
nextToken string nextToken string
nextTokenFound bool nextTokenFound bool
pageErr error
// filterEvaluator is used to filter results using go-bexpr. It's nil if
// no filter expression is defined.
filterEvaluator *bexpr.Evaluator
// appendFunc is the function the caller should use to append raw // appendFunc is the function the caller should use to append raw
// entries to the results set. The object is guaranteed to be // entries to the results set. The object is guaranteed to be
@ -21,19 +35,30 @@ type Paginator struct {
appendFunc func(interface{}) appendFunc func(interface{})
} }
func NewPaginator(iter memdb.ResultIterator, opts structs.QueryOptions, appendFunc func(interface{})) *Paginator { func NewPaginator(iter Iterator, opts structs.QueryOptions, appendFunc func(interface{})) (*Paginator, error) {
var evaluator *bexpr.Evaluator
var err error
if opts.Filter != "" {
evaluator, err = bexpr.CreateEvaluator(opts.Filter)
if err != nil {
return nil, fmt.Errorf("failed to read filter expression: %v", err)
}
}
return &Paginator{ return &Paginator{
iter: iter, iter: iter,
perPage: opts.PerPage, perPage: opts.PerPage,
seekingToken: opts.NextToken, seekingToken: opts.NextToken,
nextTokenFound: opts.NextToken == "", nextTokenFound: opts.NextToken == "",
filterEvaluator: evaluator,
appendFunc: appendFunc, appendFunc: appendFunc,
} }, nil
} }
// Page populates a page by running the append function // Page populates a page by running the append function
// over all results. Returns the next token // over all results. Returns the next token
func (p *Paginator) Page() string { func (p *Paginator) Page() (string, error) {
DONE: DONE:
for { for {
raw, andThen := p.next() raw, andThen := p.next()
@ -46,7 +71,7 @@ DONE:
break DONE break DONE
} }
} }
return p.nextToken return p.nextToken, p.pageErr
} }
func (p *Paginator) next() (interface{}, paginatorState) { func (p *Paginator) next() (interface{}, paginatorState) {
@ -62,6 +87,19 @@ func (p *Paginator) next() (interface{}, paginatorState) {
if !p.nextTokenFound && id < p.seekingToken { if !p.nextTokenFound && id < p.seekingToken {
return nil, paginatorSkip return nil, paginatorSkip
} }
// apply filter if defined
if p.filterEvaluator != nil {
match, err := p.filterEvaluator.Evaluate(raw)
if err != nil {
p.pageErr = err
return nil, paginatorComplete
}
if !match {
return nil, paginatorSkip
}
}
p.nextTokenFound = true p.nextTokenFound = true
// have we produced enough results for this page? // have we produced enough results for this page?

View File

@ -55,7 +55,7 @@ func TestPaginator(t *testing.T) {
iter := newTestIterator(ids) iter := newTestIterator(ids)
results := []string{} results := []string{}
paginator := NewPaginator(iter, paginator, err := NewPaginator(iter,
structs.QueryOptions{ structs.QueryOptions{
PerPage: tc.perPage, NextToken: tc.nextToken, PerPage: tc.perPage, NextToken: tc.nextToken,
}, },
@ -64,8 +64,10 @@ func TestPaginator(t *testing.T) {
results = append(results, result.GetID()) results = append(results, result.GetID())
}, },
) )
require.NoError(t, err)
nextToken := paginator.Page() nextToken, err := paginator.Page()
require.NoError(t, err)
require.Equal(t, tc.expected, results) require.Equal(t, tc.expected, results)
require.Equal(t, tc.expectedNextToken, nextToken) require.Equal(t, tc.expectedNextToken, nextToken)
}) })

View File

@ -19,6 +19,7 @@ const (
errUnknownNomadVersion = "Unable to determine Nomad version" errUnknownNomadVersion = "Unable to determine Nomad version"
errNodeLacksRpc = "Node does not support RPC; requires 0.8 or later" errNodeLacksRpc = "Node does not support RPC; requires 0.8 or later"
errMissingAllocID = "Missing allocation ID" errMissingAllocID = "Missing allocation ID"
errIncompatibleFiltering = "Filter expression cannot be used with other filter parameters"
// Prefix based errors that are used to check if the error is of a given // Prefix based errors that are used to check if the error is of a given
// type. These errors should be created with the associated constructor. // type. These errors should be created with the associated constructor.
@ -53,6 +54,7 @@ var (
ErrUnknownNomadVersion = errors.New(errUnknownNomadVersion) ErrUnknownNomadVersion = errors.New(errUnknownNomadVersion)
ErrNodeLacksRpc = errors.New(errNodeLacksRpc) ErrNodeLacksRpc = errors.New(errNodeLacksRpc)
ErrMissingAllocID = errors.New(errMissingAllocID) ErrMissingAllocID = errors.New(errMissingAllocID)
ErrIncompatibleFiltering = errors.New(errIncompatibleFiltering)
ErrUnknownNode = errors.New(ErrUnknownNodePrefix) ErrUnknownNode = errors.New(ErrUnknownNodePrefix)

View File

@ -274,6 +274,10 @@ type QueryOptions struct {
// AuthToken is secret portion of the ACL token used for the request // AuthToken is secret portion of the ACL token used for the request
AuthToken string AuthToken string
// Filter specifies the go-bexpr filter expression to be used for
// filtering the data prior to returning a response
Filter string
// PerPage is the number of entries to be returned in queries that support // PerPage is the number of entries to be returned in queries that support
// paginated lists. // paginated lists.
PerPage int32 PerPage int32

View File

@ -29,11 +29,11 @@ The table below shows this endpoint's support for
- `prefix` `(string: "")`- Specifies a string to filter deployments based on - `prefix` `(string: "")`- Specifies a string to filter deployments based on
an ID prefix. Because the value is decoded to bytes, the prefix must have an an ID prefix. Because the value is decoded to bytes, the prefix must have an
even number of hexadecimal characters (0-9a-f) .This is specified as a query even number of hexadecimal characters (0-9a-f) .This is specified as a query
string parameter. string parameter and is used before any `filter` expression is applied.
- `namespace` `(string: "default")` - Specifies the target - `namespace` `(string: "default")` - Specifies the target namespace.
namespace. Specifying `*` will return all evaluations across all Specifying `*` will return all evaluations across all authorized namespaces.
authorized namespaces. This parameter is used before any `filter` expression is applied.
- `next_token` `(string: "")` - This endpoint supports paging. The - `next_token` `(string: "")` - This endpoint supports paging. The
`next_token` parameter accepts a string which is the `ID` field of `next_token` parameter accepts a string which is the `ID` field of
@ -46,6 +46,10 @@ The table below shows this endpoint's support for
used as the `last_token` of the next request to fetch additional used as the `last_token` of the next request to fetch additional
pages. pages.
- `filter` `(string: "")` - Specifies the expression used to filter the query
results. Consider using pagination or a query parameter to reduce resource
used to serve the request.
- `ascending` `(bool: false)` - Specifies the list of returned deployments should - `ascending` `(bool: false)` - Specifies the list of returned deployments should
be sorted in chronological order (oldest evaluations first). By default deployments be sorted in chronological order (oldest evaluations first). By default deployments
are returned sorted in reverse chronological order (newest deployments first). are returned sorted in reverse chronological order (newest deployments first).

View File

@ -29,7 +29,7 @@ The table below shows this endpoint's support for
- `prefix` `(string: "")`- Specifies a string to filter evaluations based on an - `prefix` `(string: "")`- Specifies a string to filter evaluations based on an
ID prefix. Because the value is decoded to bytes, the prefix must have an ID prefix. Because the value is decoded to bytes, the prefix must have an
even number of hexadecimal characters (0-9a-f). This is specified as a query even number of hexadecimal characters (0-9a-f). This is specified as a query
string parameter. string parameter and and is used before any `filter` expression is applied.
- `next_token` `(string: "")` - This endpoint supports paging. The - `next_token` `(string: "")` - This endpoint supports paging. The
`next_token` parameter accepts a string which is the `ID` field of `next_token` parameter accepts a string which is the `ID` field of
@ -42,6 +42,10 @@ The table below shows this endpoint's support for
used as the `last_token` of the next request to fetch additional used as the `last_token` of the next request to fetch additional
pages. pages.
- `filter` `(string: "")` - Specifies the expression used to filter the query
results. Consider using pagination or a query parameter to reduce resource
used to serve the request.
- `job` `(string: "")` - Filter the list of evaluations to a specific - `job` `(string: "")` - Filter the list of evaluations to a specific
job ID. job ID.
@ -49,9 +53,9 @@ The table below shows this endpoint's support for
specific evaluation status (one of `blocked`, `pending`, `complete`, specific evaluation status (one of `blocked`, `pending`, `complete`,
`failed`, or `canceled`). `failed`, or `canceled`).
- `namespace` `(string: "default")` - Specifies the target - `namespace` `(string: "default")` - Specifies the target namespace.
namespace. Specifying `*` will return all evaluations across all Specifying `*` will return all evaluations across all authorized namespaces.
authorized namespaces. This parameter is used before any `filter` expression is applied.
- `ascending` `(bool: false)` - Specifies the list of returned evaluations should - `ascending` `(bool: false)` - Specifies the list of returned evaluations should
be sorted in chronological order (oldest evaluations first). By default evaluations be sorted in chronological order (oldest evaluations first). By default evaluations

View File

@ -27,6 +27,7 @@ capability for the deployment's namespace.
## List Options ## List Options
- `-json` : Output the deployments in their JSON format. - `-json` : Output the deployments in their JSON format.
- `-filter`: Specifies an expression used to filter query results.
- `-t` : Format and display the deployments using a Go template. - `-t` : Format and display the deployments using a Go template.
- `-verbose`: Show full information. - `-verbose`: Show full information.

View File

@ -29,6 +29,7 @@ capability for the requested namespace.
- `-verbose`: Show full information. - `-verbose`: Show full information.
- `-per-page`: How many results to show per page. - `-per-page`: How many results to show per page.
- `-page-token`: Where to start pagination. - `-page-token`: Where to start pagination.
- `-filter`: Specifies an expression used to filter query results.
- `-job`: Only show evaluations for this job ID. - `-job`: Only show evaluations for this job ID.
- `-status`: Only show evaluations with this status. - `-status`: Only show evaluations with this status.
- `-json`: Output the evaluation in its JSON format. - `-json`: Output the evaluation in its JSON format.