1
0
mirror of https://github.com/rancher/steve.git synced 2025-09-26 15:25:33 +00:00
This commit is contained in:
Eric Promislow
2025-04-25 14:24:21 -07:00
committed by GitHub
8 changed files with 283 additions and 144 deletions

2
go.mod
View File

@@ -14,7 +14,6 @@ require (
github.com/adrg/xdg v0.5.3 github.com/adrg/xdg v0.5.3
github.com/golang/protobuf v1.5.4 github.com/golang/protobuf v1.5.4
github.com/google/gnostic-models v0.6.9 github.com/google/gnostic-models v0.6.9
github.com/google/go-cmp v0.6.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/pborman/uuid v1.2.1 github.com/pborman/uuid v1.2.1
@@ -80,6 +79,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.0.1 // indirect github.com/google/btree v1.0.1 // indirect
github.com/google/cel-go v0.22.0 // indirect github.com/google/cel-go v0.22.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect

View File

@@ -25,10 +25,13 @@ like any other informer, but with a wider array of options. The options are conf
### List Options ### List Options
ListOptions includes the following: ListOptions includes the following:
* Match filters for indexed fields. Filters are for specifying the value a given field in an object should be in order to * Match filters for indexed fields. Filters are for specifying the value a given field in an object should be in order to
be included in the list. Filters can be set to equals or not equals. Filters can be set to look for partial matches or be included in the list. Filters are similar to the operators on labels in the `kubectl` CLI. Filters can be set to look for partial matches or
exact (strict) matches. Filters can be OR'd and AND'd with one another. Filters only work on fields that have been indexed. exact (strict) matches. Filters can be OR'd and AND'd with one another. A query of the form `filter=field1 OP1 val1,field2 OP2 val2` is an `OR` test,
* Primary field and secondary field sorting order. Can choose up to two fields to sort on. Sort order can be ascending while separate filters are AND'd together, as in `filter=field1 OP1 val1&filter=field2 OP2 val2`.
or descending. Default sorting is to sort on metadata.namespace in ascending first and then sort on metadata.name. * Filters only work on fields that have been indexed. All `metadata.labels` are also indexed.
* Any number of sort fields can be specified, but must be comma-separated in a single `sort=....` query.
Precede each field with a dash (`-`) to sort descending. The default sort is `sort=metadata.namespace,metadata.name`
(sort first by namespace, then name).
* Page size to specify how many items to include in a response. * Page size to specify how many items to include in a response.
* Page number to specify offset. For example, a page size of 50 and a page number of 2, will return items starting at * Page number to specify offset. For example, a page size of 50 and a page number of 2, will return items starting at
index 50. Index will be dependent on sort. Page numbers start at 1. index 50. Index will be dependent on sort. Page numbers start at 1.
@@ -95,10 +98,12 @@ intended to be used as a way of enforcing RBAC.
## Technical Information ## Technical Information
### SQL Tables ### SQL Tables
There are three tables that are created for the ListOption informer: There are four tables that are created for the ListOption informer:
* object table - this contains objects, including all their fields, as blobs. These blobs may be encrypted. * object table - this contains objects, including all their fields, as blobs. These blobs may be encrypted.
* fields table - this contains specific fields of value for objects. These are specified on informer create and are fields * fields table - this contains specific fields of value for objects. These are specified on informer create and are fields
that it is desired to filter or order on. that it is desired to filter or order on.
* labels table - this contains the labels for each object in the object table.
They go in a separate table because an object can have any number of labels.
* indices table - the indices table stores indexes created and objects' values for each index. This backs the generic indexer * indices table - the indices table stores indexes created and objects' values for each index. This backs the generic indexer
that contains the functionality needed to conform to cache.Indexer. that contains the functionality needed to conform to cache.Indexer.
@@ -136,16 +141,12 @@ have the following indexes by default:
### ListOptions Behavior ### ListOptions Behavior
Defaults: Defaults:
* Sort.PrimaryField: `metadata.namespace` * `sort=metadata.namespace,metadata.name` (ascending order for both)
* Sort.SecondaryField: `metadata.name`
* Sort.PrimaryOrder: `ASC` (ascending)
* Sort.SecondaryOrder: `ASC` (ascending)
* All filters have partial matching set to false by default * All filters have partial matching set to false by default
There are some uncommon ways someone could use ListOptions where it would be difficult to predict what the result would be. There are some uncommon ways someone could use ListOptions where it would be difficult to predict what the result would be.
Below is a non-exhaustive list of some of these cases and what the behavior is: Below is a non-exhaustive list of some of these cases and what the behavior is:
* Setting Pagination.Page but not Pagination.PageSize will cause Page to be ignored * Setting Pagination.Page but not Pagination.PageSize will cause Page to be ignored
* Setting Sort.SecondaryField only will sort as though it was Sort.PrimaryField. Sort.SecondaryOrder will still be applied
and Sort.PrimaryOrder will be ignored and Sort.PrimaryOrder will be ignored
### Writing Secure Queries ### Writing Secure Queries
@@ -155,3 +156,35 @@ of a query that may be user supplied, such as columns, should be carefully valid
### Troubleshooting SQLite ### Troubleshooting SQLite
A useful tool for troubleshooting the database files is the sqlite command line tool. Another useful tool is the goland A useful tool for troubleshooting the database files is the sqlite command line tool. Another useful tool is the goland
sqlite plugin. Both of these tools can be used with the database files. sqlite plugin. Both of these tools can be used with the database files.
### Indirect Querying
You can do filtering and sorting on the current table (which backs the resource name after `/v1/` in the URL)
based on related values in another table using indirect indexing. This works for both sorting and filtering.
This assumes that the main table has a field, call it field F1, with a 1:1 relation with a field F2 on some other table T2.
We then can access some other field F3 on the selected row of T2 based on F1, and then operate on that value F3.
Sorting will sort on that value (either ascending or descending), and operators will do boolean operations on that value
and compare the result against field F1.
Let's look at a specific example: sort namespaces based on the human-friendly name of the associated project's cluster:
`sort=metadata.labels[field.cattle.io/projectId] => [management.cattle.io/v3][Project][metadata.name][spec.clusterName],metadata.name`
Normally the namespaces are displayed in order of their internal name (`metadata.name`), but the above command groups them each according to the name of their enclosing cluster, using the friendly name of the cluster (`local` for the local cluster, and whatever name the user gave when creating downstream clusters).
Staying on this particular query, you can show only namespaces in the local cluster with this query:
`filter=metadata.labels[field.cattle.io/projectId] => [management.cattle.io/v3][Project][metadata.name][spec.clusterName] = local`
or show all the other namespaces:
`filter=metadata.labels[field.cattle.io/projectId] => [management.cattle.io/v3][Project][metadata.name][spec.clusterName] != local`
An implementation note: some namespaces might not have a label `field.cattle.io/projectId`. With only the sort command, these namespaces show up in the null-clusterName group after all the other groups. If you don't want to show these namespaces, you can combine a filter and a sort:
`filter=metadata.labels[field.cattle.io/projectId]&sort=metadata.labels[field.cattle.io/projectId] => [management.cattle.io/v3][Project][metadata.name][spec.clusterName],metadata.name`
The filter command uses the implicit `EXISTS` operator, available only for labels. This query selects only those namespaces that have the specied label, and the sort operates on those selected fields.
#### General Syntax
The syntax for the part to the left of the `=>` operator is the same as for other operators. The external reference, to the right of that operator, has exactly four bracketed parts: `[GROUP/API][KIND][FOREIGN-KEY-FIELD][TARGET-FIELD]`. For core Kubernetes types, leave `GROUP` empty. `KIND` is usually a capitalized singular noun. The two field names can use both dotted-accessor and square-bracket notation.

View File

@@ -40,10 +40,12 @@ type ListOptions struct {
// //
// If more than one value is given for the `Match` field, we do an "IN (<values>)" test // If more than one value is given for the `Match` field, we do an "IN (<values>)" test
type Filter struct { type Filter struct {
Field []string Field []string
Matches []string Matches []string
Op Op Op Op
Partial bool Partial bool
IsIndirect bool
IndirectFields []string
} }
// OrFilter represents a set of possible fields to filter by, where an item may match any filter in the set to be included in the result. // OrFilter represents a set of possible fields to filter by, where an item may match any filter in the set to be included in the result.
@@ -57,8 +59,10 @@ type OrFilter struct {
// The order is represented by prefixing the sort key by '-', e.g. sort=-metadata.name. // The order is represented by prefixing the sort key by '-', e.g. sort=-metadata.name.
// e.g. To sort internal clusters first followed by clusters in alpha order: sort=-spec.internal,spec.displayName // e.g. To sort internal clusters first followed by clusters in alpha order: sort=-spec.internal,spec.displayName
type Sort struct { type Sort struct {
Fields []string Fields []string
Order SortOrder Order SortOrder
IsIndirect bool
IndirectFields []string
} }
type SortList struct { type SortList struct {

View File

@@ -3,6 +3,7 @@ package listprocessor
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
@@ -72,11 +73,14 @@ func k8sRequirementToOrFilter(requirement queryparser.Requirement) (sqltypes.Fil
values := requirement.Values() values := requirement.Values()
queryFields := splitQuery(requirement.Key()) queryFields := splitQuery(requirement.Key())
op, usePartialMatch, err := k8sOpToRancherOp(requirement.Operator()) op, usePartialMatch, err := k8sOpToRancherOp(requirement.Operator())
isIndirect, indirectFields := requirement.IndirectInfo()
return sqltypes.Filter{ return sqltypes.Filter{
Field: queryFields, Field: queryFields,
Matches: values, Matches: values,
Op: op, Op: op,
Partial: usePartialMatch, Partial: usePartialMatch,
IsIndirect: isIndirect,
IndirectFields: indirectFields,
}, err }, err
} }
@@ -93,7 +97,7 @@ func ParseQuery(apiOp *types.APIRequest, namespaceCache Cache) (sqltypes.ListOpt
filterParams := q[filterParam] filterParams := q[filterParam]
filterOpts := []sqltypes.OrFilter{} filterOpts := []sqltypes.OrFilter{}
for _, filters := range filterParams { for _, filters := range filterParams {
requirements, err := queryparser.ParseToRequirements(filters) requirements, err := queryparser.ParseToRequirements(filters, filterParam)
if err != nil { if err != nil {
return sqltypes.ListOptions{}, err return sqltypes.ListOptions{}, err
} }
@@ -109,26 +113,37 @@ func ParseQuery(apiOp *types.APIRequest, namespaceCache Cache) (sqltypes.ListOpt
} }
opts.Filters = filterOpts opts.Filters = filterOpts
sortKeys := q.Get(sortParam) if q.Has(sortParam) {
if sortKeys != "" { sortKeys := q.Get(sortParam)
sortList := *sqltypes.NewSortList() filterRequirements, err := queryparser.ParseToRequirements(sortKeys, sortParam)
sortParts := strings.Split(sortKeys, ",") if err != nil {
for _, sortPart := range sortParts { return opts, err
field := sortPart }
if len(field) > 0 { if len(filterRequirements) == 0 {
sortOrder := sqltypes.ASC if len(sortKeys) == 0 {
if field[0] == '-' { return opts, errors.New("invalid sort key: <empty string>")
sortOrder = sqltypes.DESC
field = field[1:]
}
if len(field) > 0 {
sortDirective := sqltypes.Sort{
Fields: queryhelper.SafeSplit(field),
Order: sortOrder,
}
sortList.SortDirectives = append(sortList.SortDirectives, sortDirective)
}
} }
return opts, fmt.Errorf("invalid sort key: '%s'", sortKeys)
}
sortList := *sqltypes.NewSortList()
for _, requirement := range filterRequirements {
if requirement.Operator() != selection.Exists {
return opts, fmt.Errorf("sort directive %s can't contain operator (%s)", sortKeys, requirement.Operator())
}
key := requirement.Key()
order := sqltypes.ASC
if key[0] == '-' {
order = sqltypes.DESC
key = key[1:]
}
isIndirect, indirectFields := requirement.IndirectInfo()
sortDirective := sqltypes.Sort{
Fields: queryhelper.SafeSplit(key),
Order: order,
IsIndirect: isIndirect,
IndirectFields: indirectFields,
}
sortList.SortDirectives = append(sortList.SortDirectives, sortDirective)
} }
opts.SortList = sortList opts.SortList = sortList
} }

View File

@@ -370,6 +370,33 @@ func TestParseQuery(t *testing.T) {
}, },
}, },
}) })
tests = append(tests, testCase{
description: "ParseQuery() with an indirect labels filter param should create an indirect labels-specific filter.",
req: &types.APIRequest{
Request: &http.Request{
URL: &url.URL{RawQuery: "filter=metadata.labels[grover.example.com/fish]=>[_v1][Foods][foodCode][country]=japan"},
},
},
expectedLO: sqltypes.ListOptions{
ChunkSize: defaultLimit,
Filters: []sqltypes.OrFilter{
{
Filters: []sqltypes.Filter{
{
Field: []string{"metadata", "labels", "grover.example.com/fish"},
Matches: []string{"japan"},
Op: sqltypes.Eq,
IsIndirect: true,
IndirectFields: []string{"_v1", "Foods", "foodCode", "country"},
},
},
},
},
Pagination: sqltypes.Pagination{
Page: 1,
},
},
})
tests = append(tests, testCase{ tests = append(tests, testCase{
description: "ParseQuery() with multiple filter params, should include multiple or filters.", description: "ParseQuery() with multiple filter params, should include multiple or filters.",
req: &types.APIRequest{ req: &types.APIRequest{

View File

@@ -39,6 +39,9 @@ the array into a sql statement. So the set gives us no benefit apart from removi
6. We allow `lt` and `gt` as aliases for `<` and `>`. 6. We allow `lt` and `gt` as aliases for `<` and `>`.
7. We added the '~' and '!~' operators to indicate partial match and non-match 7. We added the '~' and '!~' operators to indicate partial match and non-match
8. We added indirect field selection so we can base a filter or sort off a related value
(could be in a different table)
*/ */
package queryparser package queryparser
@@ -70,6 +73,7 @@ var (
string(selection.Equals), string(selection.DoubleEquals), string(selection.NotEquals), string(selection.Equals), string(selection.DoubleEquals), string(selection.NotEquals),
string(selection.PartialEquals), string(selection.NotPartialEquals), string(selection.PartialEquals), string(selection.NotPartialEquals),
string(selection.GreaterThan), string(selection.LessThan), string(selection.GreaterThan), string(selection.LessThan),
string(selection.IndirectSelector),
} }
validRequirementOperators = append(binaryOperators, unaryOperators...) validRequirementOperators = append(binaryOperators, unaryOperators...)
labelSelectorRegex = regexp.MustCompile(`^metadata.labels(?:\.\w[-a-zA-Z0-9_./]*|\[.*])$`) labelSelectorRegex = regexp.MustCompile(`^metadata.labels(?:\.\w[-a-zA-Z0-9_./]*|\[.*])$`)
@@ -135,7 +139,9 @@ type Requirement struct {
// In huge majority of cases we have at most one value here. // In huge majority of cases we have at most one value here.
// It is generally faster to operate on a single-element slice // It is generally faster to operate on a single-element slice
// than on a single-element map, so we have a slice here. // than on a single-element map, so we have a slice here.
strValues []string strValues []string
isIndirect bool
indirectFields []string
} }
// NewRequirement is the constructor for a Requirement. // NewRequirement is the constructor for a Requirement.
@@ -183,7 +189,26 @@ func NewRequirement(key string, op selection.Operator, vals []string, opts ...fi
default: default:
allErrs = append(allErrs, field.NotSupported(path.Child("operator"), op, validRequirementOperators)) allErrs = append(allErrs, field.NotSupported(path.Child("operator"), op, validRequirementOperators))
} }
return &Requirement{key: key, operator: op, strValues: vals}, allErrs.ToAggregate() agg := allErrs.ToAggregate()
var err error
if agg != nil {
err = errors.New(agg.Error())
}
return &Requirement{key: key, operator: op, strValues: vals}, err
}
func NewIndirectRequirement(key string, indirectFields []string, newOperator *selection.Operator, targetValues []string, opts ...field.PathOption) (*Requirement, error) {
if newOperator == nil {
operator := selection.Exists
newOperator = &operator
}
r, err := NewRequirement(key, *newOperator, targetValues)
if err != nil {
return nil, err
}
r.isIndirect = true
r.indirectFields = indirectFields
return r, nil
} }
func (r *Requirement) hasValue(value string) bool { func (r *Requirement) hasValue(value string) bool {
@@ -214,6 +239,10 @@ func (r *Requirement) Values() []string {
return ret.List() return ret.List()
} }
func (r *Requirement) IndirectInfo() (bool, []string) {
return r.isIndirect, r.indirectFields
}
// Equal checks the equality of requirement. // Equal checks the equality of requirement.
func (r Requirement) Equal(x Requirement) bool { func (r Requirement) Equal(x Requirement) bool {
if r.key != x.key { if r.key != x.key {
@@ -377,6 +406,8 @@ const (
NotPartialEqualsToken NotPartialEqualsToken
// OpenParToken represents open parenthesis // OpenParToken represents open parenthesis
OpenParToken OpenParToken
// IndirectAccessToken is =>, used to associate one table with a related one, and grab a different field
IndirectAccessToken
) )
// string2token contains the mapping between lexer Token and token literal // string2token contains the mapping between lexer Token and token literal
@@ -395,6 +426,7 @@ var string2token = map[string]Token{
"!~": NotPartialEqualsToken, "!~": NotPartialEqualsToken,
"notin": NotInToken, "notin": NotInToken,
"(": OpenParToken, "(": OpenParToken,
"=>": IndirectAccessToken,
} }
// ScannedItem contains the Token and the literal produced by the lexer. // ScannedItem contains the Token and the literal produced by the lexer.
@@ -405,7 +437,7 @@ type ScannedItem struct {
func isIdentifierStartChar(ch byte) bool { func isIdentifierStartChar(ch byte) bool {
r := rune(ch) r := rune(ch)
return unicode.IsLetter(r) || unicode.IsDigit(r) || ch == '_' return unicode.IsLetter(r) || unicode.IsDigit(r) || ch == '_' || ch == '[' || ch == '-'
} }
// isWhitespace returns true if the rune is a space, tab, or newline. // isWhitespace returns true if the rune is a space, tab, or newline.
@@ -531,6 +563,7 @@ type Parser struct {
l *Lexer l *Lexer
scannedItems []ScannedItem scannedItems []ScannedItem
position int position int
parseType string
path *field.Path path *field.Path
} }
@@ -624,13 +657,18 @@ func (p *Parser) parseRequirement() (*Requirement, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
fieldPath := field.WithPath(p.path)
if operator == selection.Exists || operator == selection.DoesNotExist { // operator found lookahead set checked if operator == selection.Exists || operator == selection.DoesNotExist { // operator found lookahead set checked
if !labelSelectorRegex.MatchString(key) { if p.parseType == "filter" && !labelSelectorRegex.MatchString(key) {
return nil, fmt.Errorf("existence tests are valid only for labels; not valid for field '%s'", key) return nil, fmt.Errorf("existence tests are valid only for labels; not valid for field '%s'", key)
} }
return NewRequirement(key, operator, []string{}, field.WithPath(p.path)) return NewRequirement(key, operator, []string{}, fieldPath)
} }
operator, err = p.parseOperator() return p.parseOperatorAndValues(key, fieldPath, true)
}
func (p *Parser) parseOperatorAndValues(key string, fieldPath field.PathOption, allowIndirectSelector bool) (*Requirement, error) {
operator, err := p.parseOperator()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -640,12 +678,22 @@ func (p *Parser) parseRequirement() (*Requirement, error) {
values, err = p.parseValues() values, err = p.parseValues()
case selection.Equals, selection.DoubleEquals, selection.NotEquals, selection.GreaterThan, selection.LessThan, selection.PartialEquals, selection.NotPartialEquals: case selection.Equals, selection.DoubleEquals, selection.NotEquals, selection.GreaterThan, selection.LessThan, selection.PartialEquals, selection.NotPartialEquals:
values, err = p.parseSingleValue() values, err = p.parseSingleValue()
case selection.IndirectSelector:
if !allowIndirectSelector {
return nil, fmt.Errorf("found a subsequent indirect selector (->)")
}
indirectFields, newOperator, targetValues, err := p.parseIndirectAccessorPart(key, fieldPath)
if err != nil {
return nil, err
} else if newOperator != nil && p.parseType == "sort" {
return nil, fmt.Errorf("found an operator (%s) in a sort expression )", *newOperator)
}
return NewIndirectRequirement(key, indirectFields, newOperator, targetValues.List(), fieldPath)
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
return NewRequirement(key, operator, values.List(), field.WithPath(p.path)) return NewRequirement(key, operator, values.List(), field.WithPath(p.path))
} }
// parseKeyAndInferOperator parses literals. // parseKeyAndInferOperator parses literals.
@@ -694,11 +742,15 @@ func (p *Parser) parseOperator() (op selection.Operator, err error) {
op = selection.NotEquals op = selection.NotEquals
case NotPartialEqualsToken: case NotPartialEqualsToken:
op = selection.NotPartialEquals op = selection.NotPartialEquals
case IndirectAccessToken:
op = selection.IndirectSelector
default: default:
if lit == "lt" { if lit == "lt" {
op = selection.LessThan op = selection.LessThan
} else if lit == "gt" { } else if lit == "gt" {
op = selection.GreaterThan op = selection.GreaterThan
} else if p.parseType == "sort" {
return "", fmt.Errorf("found unexpected token '%s' in sort parameter", lit)
} else { } else {
return "", fmt.Errorf("found '%s', expected: %v", lit, strings.Join(binaryOperators, ", ")) return "", fmt.Errorf("found '%s', expected: %v", lit, strings.Join(binaryOperators, ", "))
} }
@@ -727,10 +779,38 @@ func (p *Parser) parseValues() (sets.String, error) {
p.consume(Values) p.consume(Values)
return sets.NewString(""), nil return sets.NewString(""), nil
default: default:
return nil, fmt.Errorf("found '%s', expected: ',', ')' or identifier", lit) return sets.NewString(""), fmt.Errorf("found '%s', expected: ',', ')' or identifier", lit)
} }
} }
func (p *Parser) parseIndirectAccessorPart(key string, fieldPath field.PathOption) ([]string, *selection.Operator, sets.String, error) {
//key string, indirectFields []string, newOperator selection.Operator, targetValues []string
values := sets.String{}
tok, lit := p.consume(Values)
if tok != IdentifierToken {
return nil, nil, values, fmt.Errorf("found '%s', expected: an indirect field specifier", lit)
}
matched, err := regexp.MatchString(`^(?:\[.*?\])+$`, lit)
if err != nil {
return nil, nil, values, err
} else if !matched {
return nil, nil, values, fmt.Errorf("found '%s', expected: a sequence of bracketed identifiers", lit)
}
indirectFields := strings.Split(lit[1:len(lit)-1], "][")
if len(indirectFields) != 4 {
return nil, nil, values, fmt.Errorf("found '%s', expected: a sequence of three bracketed identifiers", lit)
}
if p.parseType == "sort" {
return indirectFields, nil, sets.NewString(), nil
}
r, err := p.parseOperatorAndValues(key, fieldPath, false)
if err != nil {
return nil, nil, values, err
}
return indirectFields, &r.operator, sets.NewString(r.strValues...), nil
}
// parseIdentifiersList parses a (possibly empty) list of // parseIdentifiersList parses a (possibly empty) list of
// of comma separated (possibly empty) identifiers // of comma separated (possibly empty) identifiers
func (p *Parser) parseIdentifiersList() (sets.String, error) { func (p *Parser) parseIdentifiersList() (sets.String, error) {
@@ -814,9 +894,9 @@ func (p *Parser) parseSingleValue() (sets.String, error) {
// 4. A requirement with just a KEY - as in "y" above - denotes that // 4. A requirement with just a KEY - as in "y" above - denotes that
// the KEY exists and can be any VALUE. // the KEY exists and can be any VALUE.
// 5. A requirement with just !KEY requires that the KEY not exist. // 5. A requirement with just !KEY requires that the KEY not exist.
func Parse(selector string, opts ...field.PathOption) (Selector, error) { func Parse(selector string, parseType string, opts ...field.PathOption) (Selector, error) {
pathThing := field.ToPath(opts...) pathThing := field.ToPath(opts...)
parsedSelector, err := parse(selector, pathThing) parsedSelector, err := parse(selector, parseType, pathThing)
if err == nil { if err == nil {
return parsedSelector, nil return parsedSelector, nil
} }
@@ -827,8 +907,8 @@ func Parse(selector string, opts ...field.PathOption) (Selector, error) {
// The callers of this method can then decide how to return the internalSelector struct to their // The callers of this method can then decide how to return the internalSelector struct to their
// callers. This function has two callers now, one returns a Selector interface and the other // callers. This function has two callers now, one returns a Selector interface and the other
// returns a list of requirements. // returns a list of requirements.
func parse(selector string, path *field.Path) (internalSelector, error) { func parse(selector string, parseType string, path *field.Path) (internalSelector, error) {
p := &Parser{l: &Lexer{s: selector, pos: 0}, path: path} p := &Parser{l: &Lexer{s: selector, pos: 0}, parseType: parseType, path: path}
items, err := p.parse() items, err := p.parse()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -883,8 +963,8 @@ func SelectorFromValidatedSet(ls Set) Selector {
// processing on selector requirements. // processing on selector requirements.
// See the documentation for Parse() function for more details. // See the documentation for Parse() function for more details.
// TODO: Consider exporting the internalSelector type instead. // TODO: Consider exporting the internalSelector type instead.
func ParseToRequirements(selector string, opts ...field.PathOption) ([]Requirement, error) { func ParseToRequirements(selector string, parseType string, opts ...field.PathOption) ([]Requirement, error) {
return parse(selector, field.ToPath(opts...)) return parse(selector, parseType, field.ToPath(opts...))
} }
// ValidatedSetSelector wraps a Set, allowing it to implement the Selector interface. Unlike // ValidatedSetSelector wraps a Set, allowing it to implement the Selector interface. Unlike

View File

@@ -27,15 +27,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/rancher/steve/pkg/stores/sqlpartition/selection" "github.com/rancher/steve/pkg/stores/sqlpartition/selection"
"github.com/stretchr/testify/assert"
"k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
)
var (
ignoreDetail = cmpopts.IgnoreFields(field.Error{}, "Detail")
) )
func TestSelectorParse(t *testing.T) { func TestSelectorParse(t *testing.T) {
@@ -54,6 +48,9 @@ func TestSelectorParse(t *testing.T) {
"metadata.labels[im.here]", "metadata.labels[im.here]",
"!metadata.labels[im.not.here]", "!metadata.labels[im.not.here]",
"metadata.labels[k8s.io/meta-stuff] ~ has-dashes_underscores.dots.only", "metadata.labels[k8s.io/meta-stuff] ~ has-dashes_underscores.dots.only",
"metadata.labels[k8s.io/meta-stuff] => [management.cattle.io/v3][tokens][id][metadata.state.name] = active",
"name => [management.cattle.io/v3][tokens][id][metadata.state.name] = active",
"metadata.annotations[blah] => [management.cattle.io/v3][tokens][id][metadata.state.name] = active",
} }
testBadStrings := []string{ testBadStrings := []string{
"!no-label-absence-test", "!no-label-absence-test",
@@ -77,15 +74,22 @@ func TestSelectorParse(t *testing.T) {
"!metadata.labels(im.not.here)", "!metadata.labels(im.not.here)",
`x="no double quotes allowed"`, `x="no double quotes allowed"`,
`x='no single quotes allowed'`, `x='no single quotes allowed'`,
"metadata.labels[k8s.io/meta-stuff] => not-bracketed = active",
"metadata.labels[k8s.io/meta-stuff] => [not][enough][accessors] = active",
"metadata.labels[k8s.io/meta-stuff] => [too][many][accessors][by][1] = active",
"metadata.labels[k8s.io/meta-stuff] => [missing][an][operator][end-of-string]",
"metadata.labels[k8s.io/meta-stuff] => [missing][an][operator][no-following-operator] no-operator",
"metadata.labels[k8s.io/meta-stuff] => [missing][a][post-operator][value] >",
"metadata.labels[not/followed/by/accessor] => = active",
} }
for _, test := range testGoodStrings { for _, test := range testGoodStrings {
_, err := Parse(test) _, err := Parse(test, "filter")
if err != nil { if err != nil {
t.Errorf("%v: error %v (%#v)\n", test, err, err) t.Errorf("%v: error %v (%#v)\n", test, err, err)
} }
} }
for _, test := range testBadStrings { for _, test := range testBadStrings {
_, err := Parse(test) _, err := Parse(test, "filter")
if err == nil { if err == nil {
t.Errorf("%v: did not get expected error\n", test) t.Errorf("%v: did not get expected error\n", test)
} }
@@ -115,6 +119,7 @@ func TestLexer(t *testing.T) {
{"~", PartialEqualsToken}, {"~", PartialEqualsToken},
{"!~", NotPartialEqualsToken}, {"!~", NotPartialEqualsToken},
{"||", ErrorToken}, {"||", ErrorToken},
{"=>", IndirectAccessToken},
} }
for _, v := range testcases { for _, v := range testcases {
l := &Lexer{s: v.s, pos: 0} l := &Lexer{s: v.s, pos: 0}
@@ -163,6 +168,9 @@ func TestLexerSequence(t *testing.T) {
{"key!~ value", []Token{IdentifierToken, NotPartialEqualsToken, IdentifierToken}}, {"key!~ value", []Token{IdentifierToken, NotPartialEqualsToken, IdentifierToken}},
{"key !~value", []Token{IdentifierToken, NotPartialEqualsToken, IdentifierToken}}, {"key !~value", []Token{IdentifierToken, NotPartialEqualsToken, IdentifierToken}},
{"key!~value", []Token{IdentifierToken, NotPartialEqualsToken, IdentifierToken}}, {"key!~value", []Token{IdentifierToken, NotPartialEqualsToken, IdentifierToken}},
{"metadata.labels[k8s.io/meta-stuff] => [management.cattle.io/v3][tokens][id][metadata.state.name] = active",
[]Token{IdentifierToken, IndirectAccessToken, IdentifierToken, EqualsToken, IdentifierToken},
},
} }
for _, v := range testcases { for _, v := range testcases {
var tokens []Token var tokens []Token
@@ -203,6 +211,10 @@ func TestParserLookahead(t *testing.T) {
{"key gt 3", []Token{IdentifierToken, GreaterThanToken, IdentifierToken, EndOfStringToken}}, {"key gt 3", []Token{IdentifierToken, GreaterThanToken, IdentifierToken, EndOfStringToken}},
{"key lt 4", []Token{IdentifierToken, LessThanToken, IdentifierToken, EndOfStringToken}}, {"key lt 4", []Token{IdentifierToken, LessThanToken, IdentifierToken, EndOfStringToken}},
{`key = multi-word-string`, []Token{IdentifierToken, EqualsToken, QuotedStringToken, EndOfStringToken}}, {`key = multi-word-string`, []Token{IdentifierToken, EqualsToken, QuotedStringToken, EndOfStringToken}},
{"metadata.labels[k8s.io/meta-stuff] => [management.cattle.io/v3][tokens][id][metadata.state.name] = active",
[]Token{IdentifierToken, IndirectAccessToken, IdentifierToken, EqualsToken, IdentifierToken, EndOfStringToken},
},
} }
for _, v := range testcases { for _, v := range testcases {
p := &Parser{l: &Lexer{s: v.s, pos: 0}, position: 0} p := &Parser{l: &Lexer{s: v.s, pos: 0}, position: 0}
@@ -240,6 +252,7 @@ func TestParseOperator(t *testing.T) {
{"notin", nil}, {"notin", nil},
{"!=", nil}, {"!=", nil},
{"!~", nil}, {"!~", nil},
{"=>", nil},
{"!", fmt.Errorf("found '%s', expected: %v", selection.DoesNotExist, strings.Join(binaryOperators, ", "))}, {"!", fmt.Errorf("found '%s', expected: %v", selection.DoesNotExist, strings.Join(binaryOperators, ", "))},
{"exists", fmt.Errorf("found '%s', expected: %v", selection.Exists, strings.Join(binaryOperators, ", "))}, {"exists", fmt.Errorf("found '%s', expected: %v", selection.Exists, strings.Join(binaryOperators, ", "))},
{"(", fmt.Errorf("found '%s', expected: %v", "(", strings.Join(binaryOperators, ", "))}, {"(", fmt.Errorf("found '%s', expected: %v", "(", strings.Join(binaryOperators, ", "))},
@@ -262,30 +275,18 @@ func TestRequirementConstructor(t *testing.T) {
Key string Key string
Op selection.Operator Op selection.Operator
Vals sets.String Vals sets.String
WantErr field.ErrorList WantErr string
}{ }{
{ {
Key: "x1", Key: "x1",
Op: selection.In, Op: selection.In,
WantErr: field.ErrorList{ WantErr: "values: Invalid value: []string{}: for 'in', 'notin' operators, values set can't be empty",
&field.Error{
Type: field.ErrorTypeInvalid,
Field: "values",
BadValue: []string{},
},
},
}, },
{ {
Key: "x2", Key: "x2",
Op: selection.NotIn, Op: selection.NotIn,
Vals: sets.NewString(), Vals: sets.NewString(),
WantErr: field.ErrorList{ WantErr: "values: Invalid value: []string{}: for 'in', 'notin' operators, values set can't be empty",
&field.Error{
Type: field.ErrorTypeInvalid,
Field: "values",
BadValue: []string{},
},
},
}, },
{ {
Key: "x3", Key: "x3",
@@ -298,16 +299,10 @@ func TestRequirementConstructor(t *testing.T) {
Vals: sets.NewString("foo"), Vals: sets.NewString("foo"),
}, },
{ {
Key: "x5", Key: "x5",
Op: selection.Equals, Op: selection.Equals,
Vals: sets.NewString("foo", "bar"), Vals: sets.NewString("foo", "bar"),
WantErr: field.ErrorList{ WantErr: "values: Invalid value: []string{\"bar\", \"foo\"}: exact-match compatibility requires one single value",
&field.Error{
Type: field.ErrorTypeInvalid,
Field: "values",
BadValue: []string{"bar", "foo"},
},
},
}, },
{ {
Key: "x6", Key: "x6",
@@ -318,16 +313,10 @@ func TestRequirementConstructor(t *testing.T) {
Op: selection.DoesNotExist, Op: selection.DoesNotExist,
}, },
{ {
Key: "x8", Key: "x8",
Op: selection.Exists, Op: selection.Exists,
Vals: sets.NewString("foo"), Vals: sets.NewString("foo"),
WantErr: field.ErrorList{ WantErr: `values: Invalid value: []string{"foo"}: values set must be empty for exists and does not exist`,
&field.Error{
Type: field.ErrorTypeInvalid,
Field: "values",
BadValue: []string{"foo"},
},
},
}, },
{ {
Key: "x9", Key: "x9",
@@ -350,39 +339,21 @@ func TestRequirementConstructor(t *testing.T) {
Vals: sets.NewString("6"), Vals: sets.NewString("6"),
}, },
{ {
Key: "x13", Key: "x13",
Op: selection.GreaterThan, Op: selection.GreaterThan,
WantErr: field.ErrorList{ WantErr: "values: Invalid value: []string{}: for 'Gt', 'Lt' operators, exactly one value is required",
&field.Error{
Type: field.ErrorTypeInvalid,
Field: "values",
BadValue: []string{},
},
},
}, },
{ {
Key: "x14", Key: "x14",
Op: selection.GreaterThan, Op: selection.GreaterThan,
Vals: sets.NewString("bar"), Vals: sets.NewString("bar"),
WantErr: field.ErrorList{ WantErr: `values[0]: Invalid value: "bar": for 'Gt', 'Lt' operators, the value must be an integer`,
&field.Error{
Type: field.ErrorTypeInvalid,
Field: "values[0]",
BadValue: "bar",
},
},
}, },
{ {
Key: "x15", Key: "x15",
Op: selection.LessThan, Op: selection.LessThan,
Vals: sets.NewString("bar"), Vals: sets.NewString("bar"),
WantErr: field.ErrorList{ WantErr: `values[0]: Invalid value: "bar": for 'Gt', 'Lt' operators, the value must be an integer`,
&field.Error{
Type: field.ErrorTypeInvalid,
Field: "values[0]",
BadValue: "bar",
},
},
}, },
{ {
Key: strings.Repeat("a", 254), //breaks DNS rule that len(key) <= 253 Key: strings.Repeat("a", 254), //breaks DNS rule that len(key) <= 253
@@ -399,21 +370,29 @@ func TestRequirementConstructor(t *testing.T) {
Vals: sets.NewString("a b"), Vals: sets.NewString("a b"),
}, },
{ {
Key: "x18", Key: "x18",
Op: "unsupportedOp", Op: "unsupportedOp",
WantErr: field.ErrorList{ WantErr: `operator: Unsupported value: "unsupportedOp": supported values: "in", "notin", "=", "==", "!=", "~", "!~", "gt", "lt", "=>", "exists", "!"`,
&field.Error{
Type: field.ErrorTypeNotSupported,
Field: "operator",
BadValue: selection.Operator("unsupportedOp"),
},
},
}, },
} }
for _, rc := range requirementConstructorTests { for _, rc := range requirementConstructorTests {
_, err := NewRequirement(rc.Key, rc.Op, rc.Vals.List()) _, err := NewRequirement(rc.Key, rc.Op, rc.Vals.List())
if diff := cmp.Diff(rc.WantErr.ToAggregate(), err, ignoreDetail); diff != "" { if rc.WantErr != "" {
t.Errorf("NewRequirement test %v returned unexpected error (-want,+got):\n%s", rc.Key, diff) assert.NotNil(t, err)
if err != nil {
assert.Equal(t, rc.WantErr, err.Error())
}
} else {
assert.Nil(t, err)
}
_, err = NewIndirectRequirement(rc.Key, []string{"herb", "job", "nice", "reading"}, &rc.Op, rc.Vals.List())
if rc.WantErr != "" {
assert.NotNil(t, err)
if err != nil {
assert.Equal(t, rc.WantErr, err.Error())
}
} else {
assert.Nil(t, err)
} }
} }
} }

View File

@@ -38,4 +38,5 @@ const (
Exists Operator = "exists" Exists Operator = "exists"
GreaterThan Operator = "gt" GreaterThan Operator = "gt"
LessThan Operator = "lt" LessThan Operator = "lt"
IndirectSelector Operator = "=>"
) )