diff --git a/source/custom/custom.go b/source/custom/custom.go index 33abb7c7f..427f4a5cb 100644 --- a/source/custom/custom.go +++ b/source/custom/custom.go @@ -56,6 +56,10 @@ type customSource struct { config *config } +type legacyRule interface { + Match() (bool, error) +} + // Singleton source instance var ( src = customSource{config: newDefaultConfig()} @@ -114,7 +118,7 @@ func (s *customSource) GetLabels() (source.FeatureLabels, error) { func (s *customSource) discoverFeature(feature FeatureSpec) (bool, error) { for _, matchRules := range feature.MatchOn { - allRules := []rules.Rule{ + allRules := []legacyRule{ matchRules.PciID, matchRules.UsbID, matchRules.LoadedKMod, @@ -124,7 +128,7 @@ func (s *customSource) discoverFeature(feature FeatureSpec) (bool, error) { } // return true, nil if all rules match - matchRules := func(rules []rules.Rule) (bool, error) { + matchRules := func(rules []legacyRule) (bool, error) { for _, rule := range rules { if reflect.ValueOf(rule).IsNil() { continue diff --git a/source/custom/expression/expression.go b/source/custom/expression/expression.go new file mode 100644 index 000000000..2ccb28cc2 --- /dev/null +++ b/source/custom/expression/expression.go @@ -0,0 +1,424 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package expression + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "k8s.io/klog/v2" + + "sigs.k8s.io/node-feature-discovery/pkg/api/feature" +) + +// MatchExpressionSet contains a set of MatchExpressions, each of which is +// evaluated against a set of input values. +type MatchExpressionSet map[string]*MatchExpression + +// MatchExpression specifies an expression to evaluate against a set of input +// values. It contains an operator that is applied when matching the input and +// an array of values that the operator evaluates the input against. +// NB: CreateMatchExpression or MustCreateMatchExpression() should be used for +// creating new instances. +// NB: Validate() must be called if Op or Value fields are modified or if a new +// instance is created from scratch without using the helper functions. +type MatchExpression struct { + // Op is the operator to be applied. + Op MatchOp + + // Value is the list of values that the operand evaluates the input + // against. Value should be empty if the operator is Exists, DoesNotExist, + // IsTrue or IsFalse. Value should contain exactly one element if the + // operator is Gt or Lt. In other cases Value should contain at least one + // element. + Value MatchValue `json:",omitempty"` + + // valueRe caches compiled regexps for "InRegexp" operator + valueRe []*regexp.Regexp +} + +// MatchOp is the match operator that is applied on values when evaluating a +// MatchExpression. +type MatchOp string + +// MatchValue is the list of values associated with a MatchExpression. +type MatchValue []string + +const ( + // MatchAny returns always true. + MatchAny MatchOp = "" + // MatchIn returns true if any of the values stored in the expression is + // equal to the input. + MatchIn MatchOp = "In" + // MatchIn returns true if none of the values in the expression are equal + // to the input. + MatchNotIn MatchOp = "NotIn" + // MatchInRegexp treats values of the expression as regular expressions and + // returns true if any of them matches the input. + MatchInRegexp MatchOp = "InRegexp" + // MatchExists returns true if the input is valid. The expression must not + // have any values. + MatchExists MatchOp = "Exists" + // MatchDoesNotExist returns true if the input is not valid. The expression + // must not have any values. + MatchDoesNotExist MatchOp = "DoesNotExist" + // MatchGt returns true if the input is greater than the value of the + // expression (number of values in the expression must be exactly one). + // Both the input and value must be integer numbers, otherwise an error is + // returned. + MatchGt MatchOp = "Gt" + // MatchLt returns true if the input is less than the value of the + // expression (number of values in the expression must be exactly one). + // Both the input and value must be integer numbers, otherwise an error is + // returned. + MatchLt MatchOp = "Lt" + // MatchIsTrue returns true if the input holds the value "true". The + // expression must not have any values. + MatchIsTrue MatchOp = "IsTrue" + // MatchIsTrue returns true if the input holds the value "false". The + // expression must not have any values. + MatchIsFalse MatchOp = "IsFalse" +) + +var matchOps = map[MatchOp]struct{}{ + MatchAny: struct{}{}, + MatchIn: struct{}{}, + MatchNotIn: struct{}{}, + MatchInRegexp: struct{}{}, + MatchExists: struct{}{}, + MatchDoesNotExist: struct{}{}, + MatchGt: struct{}{}, + MatchLt: struct{}{}, + MatchIsTrue: struct{}{}, + MatchIsFalse: struct{}{}, +} + +// CreateMatchExpression creates a new MatchExpression instance. Returns an +// error if validation fails. +func CreateMatchExpression(op MatchOp, values ...string) (*MatchExpression, error) { + m := newMatchExpression(op, values...) + return m, m.Validate() +} + +// MustCreateMatchExpression creates a new MatchExpression instance. Panics if +// validation fails. +func MustCreateMatchExpression(op MatchOp, values ...string) *MatchExpression { + m, err := CreateMatchExpression(op, values...) + if err != nil { + panic(err) + } + return m +} + +// newMatchExpression returns a new MatchExpression instance. +func newMatchExpression(op MatchOp, values ...string) *MatchExpression { + return &MatchExpression{ + Op: op, + Value: values, + } +} + +// Validate validates the expression. +func (m *MatchExpression) Validate() error { + m.valueRe = nil + + if _, ok := matchOps[m.Op]; !ok { + return fmt.Errorf("invalid Op %q", m.Op) + } + switch m.Op { + case MatchExists, MatchDoesNotExist, MatchIsTrue, MatchIsFalse, MatchAny: + if len(m.Value) != 0 { + return fmt.Errorf("Value must be empty for Op %q (have %v)", m.Op, m.Value) + } + case MatchGt, MatchLt: + if len(m.Value) != 1 { + return fmt.Errorf("Value must contain exactly one element for Op %q (have %v)", m.Op, m.Value) + } + if _, err := strconv.Atoi(m.Value[0]); err != nil { + return fmt.Errorf("Value must be an integer for Op %q (have %v)", m.Op, m.Value[0]) + } + case MatchInRegexp: + if len(m.Value) == 0 { + return fmt.Errorf("Value must be non-empty for Op %q", m.Op) + } + m.valueRe = make([]*regexp.Regexp, len(m.Value)) + for i, v := range m.Value { + re, err := regexp.Compile(v) + if err != nil { + return fmt.Errorf("Value must only contain valid regexps for Op %q (have %v)", m.Op, m.Value) + } + m.valueRe[i] = re + } + default: + if len(m.Value) == 0 { + return fmt.Errorf("Value must be non-empty for Op %q", m.Op) + } + } + return nil +} + +// Match evaluates the MatchExpression against a single input value. +func (m *MatchExpression) Match(valid bool, value interface{}) (bool, error) { + switch m.Op { + case MatchAny: + return true, nil + case MatchExists: + return valid, nil + case MatchDoesNotExist: + return !valid, nil + } + + if valid { + value := fmt.Sprintf("%v", value) + switch m.Op { + case MatchIn: + for _, v := range m.Value { + if value == v { + return true, nil + } + } + case MatchNotIn: + for _, v := range m.Value { + if value == v { + return false, nil + } + } + return true, nil + case MatchInRegexp: + if m.valueRe == nil { + return false, fmt.Errorf("BUG: MatchExpression has not been initialized properly, regexps missing") + } + for _, re := range m.valueRe { + if re.MatchString(value) { + return true, nil + } + } + case MatchGt, MatchLt: + l, err := strconv.Atoi(value) + if err != nil { + return false, fmt.Errorf("not a number %q", value) + } + r, err := strconv.Atoi(m.Value[0]) + if err != nil { + return false, fmt.Errorf("not a number %q in %v", m.Value[0], m) + } + + if (l < r && m.Op == MatchLt) || (l > r && m.Op == MatchGt) { + return true, nil + } + case MatchIsTrue: + return value == "true", nil + case MatchIsFalse: + return value == "false", nil + default: + return false, fmt.Errorf("unsupported Op %q", m.Op) + } + } + return false, nil +} + +// MatchKeys evaluates the MatchExpression against a set of keys. +func (m *MatchExpression) MatchKeys(name string, keys map[string]feature.Nil) (bool, error) { + klog.V(3).Infof("matching %q %q against %v", name, m.Op, keys) + + _, ok := keys[name] + switch m.Op { + case MatchAny: + return true, nil + case MatchExists: + return ok, nil + case MatchDoesNotExist: + return !ok, nil + default: + return false, fmt.Errorf("invalid Op %q when matching keys", m.Op) + } +} + +// MatchValues evaluates the MatchExpression against a set of key-value pairs. +func (m *MatchExpression) MatchValues(name string, values map[string]string) (bool, error) { + klog.V(3).Infof("matching %q %q %v against %v", name, m.Op, m.Value, values) + v, ok := values[name] + return m.Match(ok, v) +} + +// matchExpression is a helper type for unmarshalling MatchExpression +type matchExpression MatchExpression + +// UnmarshalJSON implements the Unmarshaler interface of "encoding/json" +func (m *MatchExpression) UnmarshalJSON(data []byte) error { + raw := new(interface{}) + + err := json.Unmarshal(data, raw) + if err != nil { + return err + } + + switch v := (*raw).(type) { + case string: + *m = *newMatchExpression(MatchIn, v) + case bool: + *m = *newMatchExpression(MatchIn, strconv.FormatBool(v)) + case float64: + *m = *newMatchExpression(MatchIn, strconv.FormatFloat(v, 'f', -1, 64)) + case []interface{}: + values := make([]string, len(v)) + for i, value := range v { + str, ok := value.(string) + if !ok { + return fmt.Errorf("invalid value %v in %v", value, v) + } + values[i] = str + } + *m = *newMatchExpression(MatchIn, values...) + case map[string]interface{}: + helper := &matchExpression{} + if err := json.Unmarshal(data, &helper); err != nil { + return err + } + *m = *newMatchExpression(helper.Op, helper.Value...) + default: + return fmt.Errorf("invalid rule '%v' (%T)", v, v) + } + + return m.Validate() +} + +// MatchKeys evaluates the MatchExpressionSet against a set of keys. +func (m *MatchExpressionSet) MatchKeys(keys map[string]feature.Nil) (bool, error) { + for n, e := range *m { + match, err := e.MatchKeys(n, keys) + if err != nil { + return false, err + } + if !match { + return false, nil + } + } + return true, nil +} + +// MatchValues evaluates the MatchExpressionSet against a set of key-value pairs. +func (m *MatchExpressionSet) MatchValues(values map[string]string) (bool, error) { + for n, e := range *m { + match, err := e.MatchValues(n, values) + if err != nil { + return false, err + } + if !match { + return false, nil + } + } + return true, nil +} + +// MatchInstances evaluates the MatchExpressionSet against a set of instance +// features, each of which is an individual set of key-value pairs +// (attributes). +func (m *MatchExpressionSet) MatchInstances(instances []feature.InstanceFeature) (bool, error) { + for _, i := range instances { + if match, err := m.MatchValues(i.Attributes); err != nil { + return false, err + } else if match { + return true, nil + } + } + return false, nil +} + +// UnmarshalJSON implements the Unmarshaler interface of "encoding/json". +func (m *MatchExpressionSet) UnmarshalJSON(data []byte) error { + *m = make(MatchExpressionSet) + + names := make([]string, 0) + if err := json.Unmarshal(data, &names); err == nil { + // Simplified slice form + for _, name := range names { + split := strings.SplitN(name, "=", 2) + if len(split) == 1 { + (*m)[split[0]] = newMatchExpression(MatchExists) + } else { + (*m)[split[0]] = newMatchExpression(MatchIn, split[1]) + } + } + } else { + // Unmarshal the full map form + expressions := make(map[string]*MatchExpression) + if err := json.Unmarshal(data, &expressions); err != nil { + return err + } else { + for k, v := range expressions { + if v != nil { + (*m)[k] = v + } else { + (*m)[k] = newMatchExpression(MatchExists) + } + } + } + } + + return nil +} + +// UnmarshalJSON implements the Unmarshaler interface of "encoding/json". +func (m *MatchOp) UnmarshalJSON(data []byte) error { + var raw string + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + if _, ok := matchOps[MatchOp(raw)]; !ok { + return fmt.Errorf("invalid Op %q", raw) + } + *m = MatchOp(raw) + return nil +} + +// UnmarshalJSON implements the Unmarshaler interface of "encoding/json". +func (m *MatchValue) UnmarshalJSON(data []byte) error { + var raw interface{} + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + switch v := raw.(type) { + case string: + *m = []string{v} + case bool: + *m = []string{strconv.FormatBool(v)} + case float64: + *m = []string{strconv.FormatFloat(v, 'f', -1, 64)} + case []interface{}: + values := make([]string, len(v)) + for i, value := range v { + str, ok := value.(string) + if !ok { + return fmt.Errorf("invalid value %v in %v", value, v) + } + values[i] = str + } + *m = values + default: + return fmt.Errorf("invalid values '%v' (%T)", v, v) + } + + return nil +} diff --git a/source/custom/expression/expression_test.go b/source/custom/expression/expression_test.go new file mode 100644 index 000000000..6261336c1 --- /dev/null +++ b/source/custom/expression/expression_test.go @@ -0,0 +1,418 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package expression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/yaml" + + "sigs.k8s.io/node-feature-discovery/pkg/api/feature" + e "sigs.k8s.io/node-feature-discovery/source/custom/expression" +) + +type BoolAssertionFuncf func(assert.TestingT, bool, string, ...interface{}) bool + +type ValueAssertionFuncf func(assert.TestingT, interface{}, string, ...interface{}) bool + +func TestCreateMatchExpression(t *testing.T) { + type V = e.MatchValue + type TC struct { + op e.MatchOp + values V + err ValueAssertionFuncf + } + + tcs := []TC{ + {op: e.MatchAny, err: assert.Nilf}, // #0 + {op: e.MatchAny, values: V{"1"}, err: assert.NotNilf}, + + {op: e.MatchIn, err: assert.NotNilf}, + {op: e.MatchIn, values: V{"1"}, err: assert.Nilf}, + {op: e.MatchIn, values: V{"1", "2", "3", "4"}, err: assert.Nilf}, + + {op: e.MatchNotIn, err: assert.NotNilf}, + {op: e.MatchNotIn, values: V{"1"}, err: assert.Nilf}, + {op: e.MatchNotIn, values: V{"1", "2"}, err: assert.Nilf}, + + {op: e.MatchInRegexp, err: assert.NotNilf}, + {op: e.MatchInRegexp, values: V{"1"}, err: assert.Nilf}, + {op: e.MatchInRegexp, values: V{"()", "2", "3"}, err: assert.Nilf}, + {op: e.MatchInRegexp, values: V{"("}, err: assert.NotNilf}, + + {op: e.MatchExists, err: assert.Nilf}, + {op: e.MatchExists, values: V{"1"}, err: assert.NotNilf}, + + {op: e.MatchDoesNotExist, err: assert.Nilf}, + {op: e.MatchDoesNotExist, values: V{"1"}, err: assert.NotNilf}, + + {op: e.MatchGt, err: assert.NotNilf}, + {op: e.MatchGt, values: V{"1"}, err: assert.Nilf}, + {op: e.MatchGt, values: V{"-10"}, err: assert.Nilf}, + {op: e.MatchGt, values: V{"1", "2"}, err: assert.NotNilf}, + {op: e.MatchGt, values: V{""}, err: assert.NotNilf}, + + {op: e.MatchLt, err: assert.NotNilf}, + {op: e.MatchLt, values: V{"1"}, err: assert.Nilf}, + {op: e.MatchLt, values: V{"-1"}, err: assert.Nilf}, + {op: e.MatchLt, values: V{"1", "2", "3"}, err: assert.NotNilf}, + {op: e.MatchLt, values: V{"a"}, err: assert.NotNilf}, + + {op: e.MatchIsTrue, err: assert.Nilf}, + {op: e.MatchIsTrue, values: V{"1"}, err: assert.NotNilf}, + + {op: e.MatchIsFalse, err: assert.Nilf}, + {op: e.MatchIsFalse, values: V{"1", "2"}, err: assert.NotNilf}, + } + + for i, tc := range tcs { + _, err := e.CreateMatchExpression(tc.op, tc.values...) + tc.err(t, err, "test case #%d (%v) failed", i, tc) + } +} + +func TestMatch(t *testing.T) { + type V = e.MatchValue + type TC struct { + op e.MatchOp + values V + input interface{} + valid bool + result BoolAssertionFuncf + err ValueAssertionFuncf + } + + tcs := []TC{ + {op: e.MatchAny, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchAny, input: "2", valid: false, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchIn, values: V{"1"}, input: "2", valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIn, values: V{"1"}, input: "2", valid: true, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIn, values: V{"1", "2", "3"}, input: "2", valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIn, values: V{"1", "2", "3"}, input: "2", valid: true, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchNotIn, values: V{"2"}, input: 2, valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchNotIn, values: V{"1"}, input: 2, valid: true, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchNotIn, values: V{"1", "2", "3"}, input: "2", valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchNotIn, values: V{"1", "2", "3"}, input: "2", valid: true, result: assert.Falsef, err: assert.Nilf}, + + {op: e.MatchInRegexp, values: V{"val-[0-9]$"}, input: "val-1", valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchInRegexp, values: V{"val-[0-9]$"}, input: "val-1", valid: true, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchInRegexp, values: V{"val-[0-9]$"}, input: "val-12", valid: true, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchInRegexp, values: V{"val-[0-9]$", "al-[1-9]"}, input: "val-12", valid: true, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchExists, input: nil, valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchExists, input: nil, valid: true, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchDoesNotExist, input: false, valid: false, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchDoesNotExist, input: false, valid: true, result: assert.Falsef, err: assert.Nilf}, + + {op: e.MatchGt, values: V{"2"}, input: 3, valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchGt, values: V{"2"}, input: 2, valid: true, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchGt, values: V{"2"}, input: 3, valid: true, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchGt, values: V{"-10"}, input: -3, valid: true, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchGt, values: V{"2"}, input: "3a", valid: true, result: assert.Falsef, err: assert.NotNilf}, + + {op: e.MatchLt, values: V{"2"}, input: "1", valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchLt, values: V{"2"}, input: "2", valid: true, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchLt, values: V{"-10"}, input: -3, valid: true, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchLt, values: V{"2"}, input: "1", valid: true, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchLt, values: V{"2"}, input: "1.0", valid: true, result: assert.Falsef, err: assert.NotNilf}, + + {op: e.MatchIsTrue, input: true, valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIsTrue, input: true, valid: true, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchIsTrue, input: false, valid: true, result: assert.Falsef, err: assert.Nilf}, + + {op: e.MatchIsFalse, input: "false", valid: false, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIsFalse, input: "false", valid: true, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchIsFalse, input: "true", valid: true, result: assert.Falsef, err: assert.Nilf}, + } + + for i, tc := range tcs { + me := e.MustCreateMatchExpression(tc.op, tc.values...) + res, err := me.Match(tc.valid, tc.input) + tc.result(t, res, "test case #%d (%v) failed", i, tc) + tc.err(t, err, "test case #%d (%v) failed", i, tc) + } + + // Check some special error cases separately because MustCreateMatch panics + tcs = []TC{ + + {op: e.MatchGt, values: V{"3.0"}, input: 1, valid: true}, + {op: e.MatchLt, values: V{"0x2"}, input: 1, valid: true}, + {op: "non-existent-op", values: V{"1"}, input: 1, valid: true}, + } + + for i, tc := range tcs { + me := e.MatchExpression{Op: tc.op, Value: tc.values} + res, err := me.Match(tc.valid, tc.input) + assert.Falsef(t, res, "err test case #%d (%v) failed", i, tc) + assert.NotNilf(t, err, "err test case #%d (%v) failed", i, tc) + } +} + +func TestMatchKeys(t *testing.T) { + type V = e.MatchValue + type I = map[string]feature.Nil + type TC struct { + op e.MatchOp + values V + name string + input I + result BoolAssertionFuncf + err ValueAssertionFuncf + } + + tcs := []TC{ + {op: e.MatchAny, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchExists, name: "foo", input: nil, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchExists, name: "foo", input: I{"bar": {}}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchExists, name: "foo", input: I{"bar": {}, "foo": {}}, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchDoesNotExist, name: "foo", input: nil, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchDoesNotExist, name: "foo", input: I{}, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchDoesNotExist, name: "foo", input: I{"bar": {}}, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchDoesNotExist, name: "foo", input: I{"bar": {}, "foo": {}}, result: assert.Falsef, err: assert.Nilf}, + + // All other ops should return an error + {op: e.MatchIn, values: V{"foo"}, name: "foo", result: assert.Falsef, err: assert.NotNilf}, + {op: e.MatchNotIn, values: V{"foo"}, name: "foo", result: assert.Falsef, err: assert.NotNilf}, + {op: e.MatchInRegexp, values: V{"foo"}, name: "foo", result: assert.Falsef, err: assert.NotNilf}, + {op: e.MatchGt, values: V{"1"}, name: "foo", result: assert.Falsef, err: assert.NotNilf}, + {op: e.MatchLt, values: V{"1"}, name: "foo", result: assert.Falsef, err: assert.NotNilf}, + {op: e.MatchIsTrue, name: "foo", result: assert.Falsef, err: assert.NotNilf}, + {op: e.MatchIsFalse, name: "foo", result: assert.Falsef, err: assert.NotNilf}, + } + + for i, tc := range tcs { + me := e.MustCreateMatchExpression(tc.op, tc.values...) + res, err := me.MatchKeys(tc.name, tc.input) + tc.result(t, res, "test case #%d (%v) failed", i, tc) + tc.err(t, err, "test case #%d (%v) failed", i, tc) + } +} + +func TestMatchValues(t *testing.T) { + type V = []string + type I = map[string]string + + type TC struct { + op e.MatchOp + values V + name string + input I + result BoolAssertionFuncf + err ValueAssertionFuncf + } + + tcs := []TC{ + {op: e.MatchAny, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchIn, values: V{"1", "2"}, name: "foo", input: I{"bar": "2"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIn, values: V{"1", "2"}, name: "foo", input: I{"foo": "3"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIn, values: V{"1", "2"}, name: "foo", input: I{"foo": "2"}, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchNotIn, values: V{"1", "2"}, name: "foo", input: I{"bar": "2"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchNotIn, values: V{"1", "2"}, name: "foo", input: I{"foo": "3"}, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchNotIn, values: V{"1", "2"}, name: "foo", input: I{"foo": "2"}, result: assert.Falsef, err: assert.Nilf}, + + {op: e.MatchInRegexp, values: V{"1", "2"}, name: "foo", input: I{"bar": "2"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchInRegexp, values: V{"1", "[0-8]"}, name: "foo", input: I{"foo": "9"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchInRegexp, values: V{"1", "[0-8]"}, name: "foo", input: I{"foo": "2"}, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchExists, name: "foo", input: I{"bar": "1"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchExists, name: "foo", input: I{"foo": "1"}, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchDoesNotExist, name: "foo", input: nil, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchDoesNotExist, name: "foo", input: I{"foo": "1"}, result: assert.Falsef, err: assert.Nilf}, + + {op: e.MatchGt, values: V{"2"}, name: "foo", input: I{"bar": "3"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchGt, values: V{"2"}, name: "foo", input: I{"bar": "3", "foo": "2"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchGt, values: V{"2"}, name: "foo", input: I{"bar": "3", "foo": "3"}, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchGt, values: V{"2"}, name: "foo", input: I{"bar": "str", "foo": "str"}, result: assert.Falsef, err: assert.NotNilf}, + + {op: e.MatchLt, values: V{"2"}, name: "foo", input: I{"bar": "1"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchLt, values: V{"2"}, name: "foo", input: I{"bar": "1", "foo": "2"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchLt, values: V{"2"}, name: "foo", input: I{"bar": "1", "foo": "1"}, result: assert.Truef, err: assert.Nilf}, + {op: e.MatchLt, values: V{"2"}, name: "foo", input: I{"bar": "str", "foo": "str"}, result: assert.Falsef, err: assert.NotNilf}, + + {op: e.MatchIsTrue, name: "foo", result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIsTrue, name: "foo", input: I{"foo": "1"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIsTrue, name: "foo", input: I{"foo": "true"}, result: assert.Truef, err: assert.Nilf}, + + {op: e.MatchIsFalse, name: "foo", input: I{"foo": "true"}, result: assert.Falsef, err: assert.Nilf}, + {op: e.MatchIsFalse, name: "foo", input: I{"foo": "false"}, result: assert.Truef, err: assert.Nilf}, + } + + for i, tc := range tcs { + me := e.MustCreateMatchExpression(tc.op, tc.values...) + res, err := me.MatchValues(tc.name, tc.input) + tc.result(t, res, "test case #%d (%v) failed", i, tc) + tc.err(t, err, "test case #%d (%v) failed", i, tc) + } +} + +func TestMESMatchKeys(t *testing.T) { + type I = map[string]feature.Nil + type TC struct { + mes string + input I + result BoolAssertionFuncf + err ValueAssertionFuncf + } + + tcs := []TC{ + {result: assert.Truef, err: assert.Nilf}, + + {input: I{"foo": {}}, result: assert.Truef, err: assert.Nilf}, + + {mes: ` +foo: { op: DoesNotExist } +bar: { op: Exists } +`, + input: I{"bar": {}, "baz": {}}, + result: assert.Truef, err: assert.Nilf}, + + {mes: ` +foo: { op: DoesNotExist } +bar: { op: Exists } +`, + input: I{"foo": {}, "bar": {}, "baz": {}}, + result: assert.Falsef, err: assert.Nilf}, + + {mes: ` +foo: { op: In, value: ["bar"] } +bar: { op: Exists } +`, + input: I{"bar": {}, "baz": {}}, + result: assert.Falsef, err: assert.NotNilf}, + } + + for i, tc := range tcs { + mes := &e.MatchExpressionSet{} + if err := yaml.Unmarshal([]byte(tc.mes), mes); err != nil { + t.Fatalf("failed to parse data of test case #%d (%v): %v", i, tc, err) + } + + res, err := mes.MatchKeys(tc.input) + tc.result(t, res, "test case #%d (%v) failed", i, tc) + tc.err(t, err, "test case #%d (%v) failed", i, tc) + } +} + +func TestMESMatchValues(t *testing.T) { + type I = map[string]string + type TC struct { + mes string + input I + result BoolAssertionFuncf + err ValueAssertionFuncf + } + + tcs := []TC{ + {result: assert.Truef, err: assert.Nilf}, + + {input: I{"foo": "bar"}, result: assert.Truef, err: assert.Nilf}, + + {mes: ` +foo: { op: Exists } +bar: { op: In, value: ["val", "wal"] } +baz: { op: Gt, value: ["10"] } +`, + input: I{"bar": "val"}, + result: assert.Falsef, err: assert.Nilf}, + + {mes: ` +foo: { op: Exists } +bar: { op: In, value: ["val", "wal"] } +baz: { op: Gt, value: ["10"] } +`, + input: I{"foo": "1", "bar": "val", "baz": "123"}, + result: assert.Truef, err: assert.Nilf}, + + {mes: ` +foo: { op: Exists } +bar: { op: In, value: ["val"] } +baz: { op: Gt, value: ["10"] } +`, + input: I{"foo": "1", "bar": "val", "baz": "123.0"}, + result: assert.Falsef, err: assert.NotNilf}, + } + + for i, tc := range tcs { + mes := &e.MatchExpressionSet{} + if err := yaml.Unmarshal([]byte(tc.mes), mes); err != nil { + t.Fatalf("failed to parse data of test case #%d (%v): %v", i, tc, err) + } + + res, err := mes.MatchValues(tc.input) + tc.result(t, res, "test case #%d (%v) failed", i, tc) + tc.err(t, err, "test case #%d (%v) failed", i, tc) + } +} + +func TestMESMatchInstances(t *testing.T) { + type I = feature.InstanceFeature + type A = map[string]string + type TC struct { + mes string + input []I + result BoolAssertionFuncf + err ValueAssertionFuncf + } + + tcs := []TC{ + {result: assert.Falsef, err: assert.Nilf}, // nil instances -> false + + {input: []I{}, result: assert.Falsef, err: assert.Nilf}, // zero instances -> false + + {input: []I{I{Attributes: A{}}}, result: assert.Truef, err: assert.Nilf}, // one "empty" instance + + {mes: ` +foo: { op: Exists } +bar: { op: Lt, value: ["10"] } +`, + input: []I{I{Attributes: A{"foo": "1"}}, I{Attributes: A{"bar": "1"}}}, + result: assert.Falsef, err: assert.Nilf}, + + {mes: ` +foo: { op: Exists } +bar: { op: Lt, value: ["10"] } +`, + input: []I{I{Attributes: A{"foo": "1"}}, I{Attributes: A{"foo": "2", "bar": "1"}}}, + result: assert.Truef, err: assert.Nilf}, + + {mes: ` +bar: { op: Lt, value: ["10"] } +`, + input: []I{I{Attributes: A{"foo": "1"}}, I{Attributes: A{"bar": "0x1"}}}, + result: assert.Falsef, err: assert.NotNilf}, + } + + for i, tc := range tcs { + mes := &e.MatchExpressionSet{} + if err := yaml.Unmarshal([]byte(tc.mes), mes); err != nil { + t.Fatalf("failed to parse data of test case #%d (%v): %v", i, tc, err) + } + + res, err := mes.MatchInstances(tc.input) + tc.result(t, res, "test case #%d (%v) failed", i, tc) + tc.err(t, err, "test case #%d (%v) failed", i, tc) + } +} diff --git a/source/custom/rules/cpuid_rule.go b/source/custom/rules/cpuid_rule.go index 40117e965..fda326aa1 100644 --- a/source/custom/rules/cpuid_rule.go +++ b/source/custom/rules/cpuid_rule.go @@ -21,21 +21,18 @@ import ( "sigs.k8s.io/node-feature-discovery/source" "sigs.k8s.io/node-feature-discovery/source/cpu" + "sigs.k8s.io/node-feature-discovery/source/custom/expression" ) // CpuIDRule implements Rule for the custom source -type CpuIDRule []string +type CpuIDRule struct { + expression.MatchExpressionSet +} -func (cpuids *CpuIDRule) Match() (bool, error) { +func (r *CpuIDRule) Match() (bool, error) { flags, ok := source.GetFeatureSource("cpu").GetFeatures().Keys[cpu.CpuidFeature] if !ok { return false, fmt.Errorf("cpuid information not available") } - - for _, f := range *cpuids { - if _, ok := flags.Elements[f]; !ok { - return false, nil - } - } - return true, nil + return r.MatchKeys(flags.Elements) } diff --git a/source/custom/rules/kconfig_rule.go b/source/custom/rules/kconfig_rule.go index d7cd3d9ce..a8c79a823 100644 --- a/source/custom/rules/kconfig_rule.go +++ b/source/custom/rules/kconfig_rule.go @@ -17,48 +17,22 @@ limitations under the License. package rules import ( - "encoding/json" "fmt" - "strings" "sigs.k8s.io/node-feature-discovery/source" + "sigs.k8s.io/node-feature-discovery/source/custom/expression" "sigs.k8s.io/node-feature-discovery/source/kernel" ) -// KconfigRule implements Rule -type KconfigRule []kconfig - -type kconfig struct { - Name string - Value string +// KconfigRule implements Rule for the custom source +type KconfigRule struct { + expression.MatchExpressionSet } -func (kconfigs *KconfigRule) Match() (bool, error) { +func (r *KconfigRule) Match() (bool, error) { options, ok := source.GetFeatureSource("kernel").GetFeatures().Values[kernel.ConfigFeature] if !ok { return false, fmt.Errorf("kernel config options not available") } - - for _, f := range *kconfigs { - if v, ok := options.Elements[f.Name]; !ok || f.Value != v { - return false, nil - } - } - return true, nil -} - -func (c *kconfig) UnmarshalJSON(data []byte) error { - var raw string - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - split := strings.SplitN(raw, "=", 2) - c.Name = split[0] - if len(split) == 1 { - c.Value = "true" - } else { - c.Value = split[1] - } - return nil + return r.MatchValues(options.Elements) } diff --git a/source/custom/rules/loaded_kmod_rule.go b/source/custom/rules/loaded_kmod_rule.go index 1fdb546f8..38a3be060 100644 --- a/source/custom/rules/loaded_kmod_rule.go +++ b/source/custom/rules/loaded_kmod_rule.go @@ -20,24 +20,21 @@ import ( "fmt" "sigs.k8s.io/node-feature-discovery/source" + "sigs.k8s.io/node-feature-discovery/source/custom/expression" "sigs.k8s.io/node-feature-discovery/source/kernel" ) // LoadedKModRule matches loaded kernel modules in the system -type LoadedKModRule []string +type LoadedKModRule struct { + expression.MatchExpressionSet +} // Match loaded kernel modules on provided list of kernel modules -func (kmods *LoadedKModRule) Match() (bool, error) { +func (r *LoadedKModRule) Match() (bool, error) { modules, ok := source.GetFeatureSource("kernel").GetFeatures().Keys[kernel.LoadedModuleFeature] if !ok { return false, fmt.Errorf("info about loaded modules not available") } - for _, kmod := range *kmods { - if _, ok := modules.Elements[kmod]; !ok { - // kernel module not loaded - return false, nil - } - } - return true, nil + return r.MatchKeys(modules.Elements) } diff --git a/source/custom/rules/nodename_rule.go b/source/custom/rules/nodename_rule.go index f1a9af18a..7f5724f93 100644 --- a/source/custom/rules/nodename_rule.go +++ b/source/custom/rules/nodename_rule.go @@ -17,40 +17,35 @@ limitations under the License. package rules import ( + "encoding/json" "fmt" - "regexp" - - "k8s.io/klog/v2" "sigs.k8s.io/node-feature-discovery/source" + "sigs.k8s.io/node-feature-discovery/source/custom/expression" "sigs.k8s.io/node-feature-discovery/source/system" ) // NodenameRule matches on nodenames configured in a ConfigMap -type NodenameRule []string +type NodenameRule struct { + expression.MatchExpression +} -// Force implementation of Rule -var _ Rule = NodenameRule{} - -func (n NodenameRule) Match() (bool, error) { +func (r *NodenameRule) Match() (bool, error) { nodeName, ok := source.GetFeatureSource("system").GetFeatures().Values[system.NameFeature].Elements["nodename"] - if !ok { + if !ok || nodeName == "" { return false, fmt.Errorf("node name not available") } - - for _, nodenamePattern := range n { - klog.V(1).Infof("matchNodename %s", nodenamePattern) - match, err := regexp.MatchString(nodenamePattern, nodeName) - if err != nil { - klog.Errorf("nodename rule: invalid nodename regexp %q: %v", nodenamePattern, err) - continue - } - if !match { - klog.V(2).Infof("nodename rule: No match for pattern %q with node %q", nodenamePattern, nodeName) - continue - } - klog.V(2).Infof("nodename rule: Match for pattern %q with node %q", nodenamePattern, nodeName) - return true, nil - } - return false, nil + return r.MatchExpression.Match(true, nodeName) +} + +func (r *NodenameRule) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &r.MatchExpression); err != nil { + return err + } + // Force regexp matching + if r.Op == expression.MatchIn { + r.Op = expression.MatchInRegexp + } + // We need to run Validate() because operator forcing above + return r.Validate() } diff --git a/source/custom/rules/pci_id_rule.go b/source/custom/rules/pci_id_rule.go index 4ce7e97e4..28793e341 100644 --- a/source/custom/rules/pci_id_rule.go +++ b/source/custom/rules/pci_id_rule.go @@ -19,23 +19,13 @@ package rules import ( "fmt" - "sigs.k8s.io/node-feature-discovery/pkg/api/feature" "sigs.k8s.io/node-feature-discovery/source" + "sigs.k8s.io/node-feature-discovery/source/custom/expression" "sigs.k8s.io/node-feature-discovery/source/pci" ) -// Rule that matches on the following PCI device attributes: -// each device attribute will be a list elements(strings). -// Match operation: OR will be performed per element and AND will be performed per attribute. -// An empty attribute will not be included in the matching process. -type PciIDRuleInput struct { - Class []string `json:"class,omitempty"` - Vendor []string `json:"vendor,omitempty"` - Device []string `json:"device,omitempty"` -} - type PciIDRule struct { - PciIDRuleInput + expression.MatchExpressionSet } // Match PCI devices on provided PCI device attributes @@ -45,46 +35,5 @@ func (r *PciIDRule) Match() (bool, error) { return false, fmt.Errorf("cpuid information not available") } - devAttr := map[string]bool{} - for _, attr := range []string{"class", "vendor", "device"} { - devAttr[attr] = true - } - - for _, dev := range devs.Elements { - // match rule on a single device - if r.matchDevOnRule(dev) { - return true, nil - } - } - return false, nil -} - -func (r *PciIDRule) matchDevOnRule(dev feature.InstanceFeature) bool { - if len(r.Class) == 0 && len(r.Vendor) == 0 && len(r.Device) == 0 { - return false - } - - attrs := dev.Attributes - if len(r.Class) > 0 && !in(attrs["class"], r.Class) { - return false - } - - if len(r.Vendor) > 0 && !in(attrs["vendor"], r.Vendor) { - return false - } - - if len(r.Device) > 0 && !in(attrs["device"], r.Device) { - return false - } - - return true -} - -func in(item string, arr []string) bool { - for _, val := range arr { - if val == item { - return true - } - } - return false + return r.MatchInstances(devs.Elements) } diff --git a/source/custom/rules/rule.go b/source/custom/rules/rule.go deleted file mode 100644 index 5632fccd0..000000000 --- a/source/custom/rules/rule.go +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2020 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package rules - -type Rule interface { - // Match on rule - Match() (bool, error) -} diff --git a/source/custom/rules/usb_id_rule.go b/source/custom/rules/usb_id_rule.go index 058ec357b..23f53e602 100644 --- a/source/custom/rules/usb_id_rule.go +++ b/source/custom/rules/usb_id_rule.go @@ -19,24 +19,13 @@ package rules import ( "fmt" - "sigs.k8s.io/node-feature-discovery/pkg/api/feature" "sigs.k8s.io/node-feature-discovery/source" + "sigs.k8s.io/node-feature-discovery/source/custom/expression" "sigs.k8s.io/node-feature-discovery/source/usb" ) -// Rule that matches on the following USB device attributes: -// each device attribute will be a list elements(strings). -// Match operation: OR will be performed per element and AND will be performed per attribute. -// An empty attribute will not be included in the matching process. -type UsbIDRuleInput struct { - Class []string `json:"class,omitempty"` - Vendor []string `json:"vendor,omitempty"` - Device []string `json:"device,omitempty"` - Serial []string `json:"serial,omitempty"` -} - type UsbIDRule struct { - UsbIDRuleInput + expression.MatchExpressionSet } // Match USB devices on provided USB device attributes @@ -45,37 +34,5 @@ func (r *UsbIDRule) Match() (bool, error) { if !ok { return false, fmt.Errorf("usb device information not available") } - - for _, dev := range devs.Elements { - // match rule on a single device - if r.matchDevOnRule(dev) { - return true, nil - } - } - return false, nil -} - -func (r *UsbIDRule) matchDevOnRule(dev feature.InstanceFeature) bool { - if len(r.Class) == 0 && len(r.Vendor) == 0 && len(r.Device) == 0 { - return false - } - - attrs := dev.Attributes - if len(r.Class) > 0 && !in(attrs["class"], r.Class) { - return false - } - - if len(r.Vendor) > 0 && !in(attrs["vendor"], r.Vendor) { - return false - } - - if len(r.Device) > 0 && !in(attrs["device"], r.Device) { - return false - } - - if len(r.Serial) > 0 && !in(attrs["serial"], r.Serial) { - return false - } - - return true + return r.MatchInstances(devs.Elements) } diff --git a/source/custom/static_features.go b/source/custom/static_features.go index c3e48165b..3d632e479 100644 --- a/source/custom/static_features.go +++ b/source/custom/static_features.go @@ -17,6 +17,7 @@ limitations under the License. package custom import ( + "sigs.k8s.io/node-feature-discovery/source/custom/expression" "sigs.k8s.io/node-feature-discovery/source/custom/rules" ) @@ -29,7 +30,9 @@ func getStaticFeatureConfig() []FeatureSpec { MatchOn: []MatchRule{ { PciID: &rules.PciIDRule{ - PciIDRuleInput: rules.PciIDRuleInput{Vendor: []string{"15b3"}}, + MatchExpressionSet: expression.MatchExpressionSet{ + "vendor": expression.MustCreateMatchExpression(expression.MatchIn, "15b3"), + }, }, }, }, @@ -38,7 +41,12 @@ func getStaticFeatureConfig() []FeatureSpec { Name: "rdma.available", MatchOn: []MatchRule{ { - LoadedKMod: &rules.LoadedKModRule{"ib_uverbs", "rdma_ucm"}, + LoadedKMod: &rules.LoadedKModRule{ + MatchExpressionSet: expression.MatchExpressionSet{ + "ib_uverbs": expression.MustCreateMatchExpression(expression.MatchExists), + "rdma_ucm": expression.MustCreateMatchExpression(expression.MatchExists), + }, + }, }, }, },