diff --git a/pkg/apis/resmgr/v1alpha1/evaluable.go b/pkg/apis/resmgr/v1alpha1/evaluable.go new file mode 100644 index 000000000..72c014a0b --- /dev/null +++ b/pkg/apis/resmgr/v1alpha1/evaluable.go @@ -0,0 +1,20 @@ +// Copyright 2019-2020 Intel Corporation. All Rights Reserved. +// +// 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 resmgr + +// Evaluable is the interface objects need to implement to be evaluable against Expressions. +type Evaluable interface { + Eval(string) interface{} +} diff --git a/pkg/apis/resmgr/v1alpha1/expression.go b/pkg/apis/resmgr/v1alpha1/expression.go index 1cfe0a0fd..9784e9baa 100644 --- a/pkg/apis/resmgr/v1alpha1/expression.go +++ b/pkg/apis/resmgr/v1alpha1/expression.go @@ -23,57 +23,6 @@ import ( logger "github.com/containers/nri-plugins/pkg/log" ) -// Evaluable is the interface objects need to implement to be evaluable against Expressions. -type Evaluable interface { - Eval(string) interface{} -} - -// Expression is used to describe a criteria to select objects within a domain. -type Expression struct { - Key string `json:"key"` // key to check values of/against - Op Operator `json:"operator"` // operator to apply to value of Key and Values - Values []string `json:"values,omitempty"` // value(s) for domain key -} - -const ( - KeyPod = "pod" - KeyID = "id" - KeyUID = "uid" - KeyName = "name" - KeyNamespace = "namespace" - KeyQOSClass = "qosclass" - KeyLabels = "labels" - KeyTags = "tags" -) - -// Operator defines the possible operators for an Expression. -type Operator string - -const ( - // Equals tests for equality with a single value. - Equals Operator = "Equals" - // NotEqual test for inequality with a single value. - NotEqual Operator = "NotEqual" - // In tests if the key's value is one of the specified set. - In Operator = "In" - // NotIn tests if the key's value is not one of the specified set. - NotIn Operator = "NotIn" - // Exists evalutes to true if the named key exists. - Exists Operator = "Exists" - // NotExist evalutes to true if the named key does not exist. - NotExist Operator = "NotExist" - // AlwaysTrue always evaluates to true. - AlwaysTrue Operator = "AlwaysTrue" - // Matches tests if the key value matches the only given globbing pattern. - Matches Operator = "Matches" - // MatchesNot is true if Matches would be false for the same key and pattern. - MatchesNot Operator = "MatchesNot" - // MatchesAny tests if the key value matches any of the given globbing patterns. - MatchesAny Operator = "MatchesAny" - // MatchesNone is true if MatchesAny would be false for the same key and patterns. - MatchesNone Operator = "MatchesNone" -) - // Our logger instance. var log = logger.NewLogger("expression") @@ -83,6 +32,10 @@ func (e *Expression) Validate() error { return exprError("nil expression") } + if err := e.validateKey(); err != nil { + return err + } + switch e.Op { case Equals, NotEqual: if len(e.Values) != 1 { @@ -99,7 +52,11 @@ func (e *Expression) Validate() error { case In, NotIn: case MatchesAny, MatchesNone: + case AlwaysTrue: + if e.Values != nil && len(e.Values) != 0 { + return exprError("invalid expression, '%s' does not take any values", e.Op) + } default: return exprError("invalid expression, unknown operator: %q", e.Op) @@ -107,6 +64,56 @@ func (e *Expression) Validate() error { return nil } +func (e *Expression) validateKey() error { + keys, _ := splitKeys(e.Key) + +VALIDATE_KEYS: + for _, key := range keys { + key = strings.TrimLeft(key, "/") + for { + prefKey, restKey, _ := strings.Cut(key, "/") + switch prefKey { + case KeyID, KeyUID, KeyName, KeyNamespace, KeyQOSClass: + if restKey != "" { + return exprError("invalid expression, trailing key %q after %q", + prefKey, restKey) + } + + case KeyPod: + if restKey == "" { + return exprError("invalid expression, missing trailing pod key after %q", + prefKey) + } + + case KeyLabels: + if restKey == "" { + return exprError("invalid expression, missing trailing map key after %q", + prefKey) + } + continue VALIDATE_KEYS // validate next key, assuming rest is label map key + + case KeyTags: + if restKey == "" { + return exprError("invalid expression, missing trailing map key after %q", + prefKey) + } + continue VALIDATE_KEYS // validate next key, assuming rest is tag map key + + default: + return exprError("invalid expression, unknown key %q", prefKey) + } + + if restKey == "" { + break + } + + key = restKey + } + } + + return nil +} + // Evaluate evaluates an expression against a container. func (e *Expression) Evaluate(subject Evaluable) bool { log.Debug("evaluating %q @ %s...", *e, subject) @@ -192,9 +199,15 @@ func (e *Expression) KeyValue(subject Evaluable) (string, bool) { } func splitKeys(keys string) ([]string, string) { - // joint key specs have two valid forms: + // We don't support boolean expressions but we support 'joint keys'. + // These can be used to emulate a boolean AND of multiple keys. + // + // Joint keys have two valid forms: // - ":keylist" (equivalent to ":::") - // - ":" + // - ":" + // + // The value of dereferencing such a key is the values of all individual + // keys concatenated and separated by value-sep. if len(keys) < 4 || keys[0] != ':' { return []string{keys}, "" @@ -229,56 +242,58 @@ func validSeparator(b byte) bool { } // ResolveRef walks an object trying to resolve a reference to a value. +// +// Keys can be combined into compound keys using '/' as the separator. +// For instance, "pod/labels/io.test.domain/my-label" refers to the +// value of the "io.test.domain/my-label" label key of the pod of the +// evaluated object. func ResolveRef(subject Evaluable, spec string) (string, bool, error) { - var obj interface{} + var ( + key = path.Clean(spec) + obj interface{} = subject + ) - log.Debug("resolving %q @ %s...", spec, subject) + log.Debug("resolving %q in %s...", key, subject) - spec = path.Clean(spec) - ref := strings.Split(spec, "/") - if len(ref) == 1 { - if strings.Index(spec, ".") != -1 { - ref = []string{"labels", spec} - } - } + for { + log.Debug("- resolve %q in %s", key, obj) - obj = subject - for len(ref) > 0 { - key := ref[0] - - log.Debug("resolve walking %q @ %s...", key, obj) switch v := obj.(type) { - case string: - obj = v + case Evaluable: + pref, rest, _ := strings.Cut(key, "/") + obj = v.Eval(pref) + key = rest + case map[string]string: value, ok := v[key] if !ok { return "", false, nil } obj = value + key = "" + case error: return "", false, exprError("%s: failed to resolve %q: %v", subject, spec, v) + default: - e, ok := obj.(Evaluable) - if !ok { - return "", false, exprError("%s: failed to resolve %q, unexpected type %T", - subject, spec, obj) - } - obj = e.Eval(key) + return "", false, exprError("%s: failed to resolve %q (%q): wrong type %T", + subject, key, spec, v) } - ref = ref[1:] + if key == "" { + break + } } - str, ok := obj.(string) + s, ok := obj.(string) if !ok { - return "", false, exprError("%s: reference %q resolved to non-string: %T", + return "", false, exprError("%s: failed to resolve %q: non-string type %T", subject, spec, obj) } - log.Debug("resolved %q @ %s => %s", spec, subject, str) + log.Debug("resolved %q in %s => %s", spec, subject, s) - return str, true, nil + return s, true, nil } // String returns the expression as a string. @@ -286,21 +301,6 @@ func (e *Expression) String() string { return fmt.Sprintf("<%s %s %s>", e.Key, e.Op, strings.Join(e.Values, ",")) } -// DeepCopy creates a deep copy of the expression. -func (e *Expression) DeepCopy() *Expression { - out := &Expression{} - e.DeepCopyInto(out) - return out -} - -// DeepCopyInto copies the expression into another one. -func (e *Expression) DeepCopyInto(out *Expression) { - out.Key = e.Key - out.Op = e.Op - out.Values = make([]string, len(e.Values)) - copy(out.Values, e.Values) -} - // exprError returns a formatted error specific to expressions. func exprError(format string, args ...interface{}) error { return fmt.Errorf("expression: "+format, args...) diff --git a/pkg/apis/resmgr/v1alpha1/expression_test.go b/pkg/apis/resmgr/v1alpha1/expression_test.go index 52829c9c3..4556d1995 100644 --- a/pkg/apis/resmgr/v1alpha1/expression_test.go +++ b/pkg/apis/resmgr/v1alpha1/expression_test.go @@ -20,6 +20,7 @@ import ( "testing" logger "github.com/containers/nri-plugins/pkg/log" + "github.com/stretchr/testify/require" ) type evaluable struct { @@ -86,7 +87,12 @@ func TestResolveRefAndKeyValue(t *testing.T) { defer logger.Flush() pod := newEvaluable("P1", "pns", "pqos", - map[string]string{"l1": "plone", "l2": "pltwo", "l5": "plfive"}, nil, nil) + map[string]string{ + "l1": "plone", + "l2": "pltwo", + "l5": "plfive", + "io.test/label1": "io.test/value1", + }, nil, nil) tcases := []struct { name string @@ -100,7 +106,11 @@ func TestResolveRefAndKeyValue(t *testing.T) { { name: "test resolving references", subject: newEvaluable("C1", "cns", "cqos", - map[string]string{"l1": "clone", "l2": "cltwo", "l3": "clthree"}, + map[string]string{ + "l1": "clone", + "l2": "cltwo", + "l3": "clthree", + }, map[string]string{"t1": "ctone", "t2": "cttwo", "t3": "ctthree"}, pod), keys: []string{ "name", "namespace", "qosclass", @@ -111,34 +121,35 @@ func TestResolveRefAndKeyValue(t *testing.T) { "pod/labels/l3", "pod/labels/l4", "pod/labels/l5", + "pod/labels/io.test/label1", ":,-pod/qosclass,pod/namespace,pod/name,name", }, values: []string{ "C1", "cns", "cqos", "clone", "cltwo", "clthree", "", "ctone", "cttwo", "ctthree", "", - "plone", "pltwo", "", "", "plfive", + "plone", "pltwo", "", "", "plfive", "io.test/value1", "", }, keyvalues: []string{ "C1", "cns", "cqos", "clone", "cltwo", "clthree", "", "ctone", "cttwo", "ctthree", "", - "plone", "pltwo", "", "", "plfive", + "plone", "pltwo", "", "", "plfive", "io.test/value1", "pqos-pns-P1-C1", }, ok: []bool{ true, true, true, true, true, true, false, true, true, true, false, - true, true, false, false, true, + true, true, false, false, true, true, false, }, error: []bool{ false, false, false, false, false, false, false, false, false, false, false, - false, false, false, false, false, + false, false, false, false, false, false, true, }, }, @@ -377,3 +388,151 @@ func TestMatching(t *testing.T) { }) } } + +func TestValidation(t *testing.T) { + defer logger.Flush() + + for _, tc := range []*struct { + name string + expr *Expression + invalid bool + }{ + { + name: "valid ID reference", + expr: &Expression{ + Key: "id", + Op: Equals, + Values: []string{"a"}, + }, + }, + { + name: "valid uid reference", + expr: &Expression{ + Key: "uid", + Op: Equals, + Values: []string{"a"}, + }, + }, + { + name: "valid name reference", + expr: &Expression{ + Key: "name", + Op: Equals, + Values: []string{"a"}, + }, + }, + { + name: "valid namespace reference", + expr: &Expression{ + Key: "namespace", + Op: Equals, + Values: []string{"a"}, + }, + }, + { + name: "valid QoS class reference", + expr: &Expression{ + Key: "qosclass", + Op: Equals, + Values: []string{"a"}, + }, + }, + { + name: "valid label reference", + expr: &Expression{ + Key: "labels/io.kubernetes.application", + Op: Equals, + Values: []string{"test"}, + }, + }, + { + name: "valid pod reference", + expr: &Expression{ + Key: "pod/name", + Op: Equals, + Values: []string{"test"}, + }, + }, + { + name: "invalid pod reference, no trailing key", + expr: &Expression{ + Key: "pod", + Op: Equals, + Values: []string{"test"}, + }, + invalid: true, + }, + { + name: "invalid pod reference, unknown trailing key", + expr: &Expression{ + Key: "pod/foo", + Op: Equals, + Values: []string{"test"}, + }, + invalid: true, + }, + { + name: "invalid name reference, trailing key", + expr: &Expression{ + Key: "name/foo", + Op: Equals, + Values: []string{"a"}, + }, + invalid: true, + }, + { + name: "invalid equal, wrong number of arguments", + expr: &Expression{ + Key: "name", + Op: Equals, + Values: []string{}, + }, + invalid: true, + }, + { + name: "invalid NotEqual, wrong number of arguments", + expr: &Expression{ + Key: "name", + Op: NotEqual, + Values: []string{"a", "b"}, + }, + invalid: true, + }, + { + name: "invalid Matches, wrong number of arguments", + expr: &Expression{ + Key: "name", + Op: Matches, + Values: []string{}, + }, + invalid: true, + }, + { + name: "invalid MatchesNot, wrong number of arguments", + expr: &Expression{ + Key: "name", + Op: MatchesNot, + Values: []string{"a", "b"}, + }, + invalid: true, + }, + { + name: "invalid AlwaysTrue, wrong number of arguments", + expr: &Expression{ + Key: "name", + Op: AlwaysTrue, + Values: []string{"c"}, + }, + invalid: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.expr.Validate() + if tc.invalid { + require.NotNil(t, err) + } else { + require.Nil(t, err) + } + }) + } +} diff --git a/pkg/apis/resmgr/v1alpha1/types.go b/pkg/apis/resmgr/v1alpha1/types.go new file mode 100644 index 000000000..22072a051 --- /dev/null +++ b/pkg/apis/resmgr/v1alpha1/types.go @@ -0,0 +1,84 @@ +// Copyright The NRI Plugins Authors. All Rights Reserved. +// +// 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 resmgr + +// Expression describes some runtime-evaluated condition. An expression +// consist of a key, an operator and a set of values. An expressions is +// evaluated against an object which implements the Evaluable interface. +// Evaluating an expression consists of looking up the value for the key +// in the object, then using the operator to check it agains the values +// of the expression. The result is a single boolean value. An object is +// said to satisfy the evaluated expression if this value is true. An +// expression can contain 0, 1 or more values depending on the operator. +// +k8s:deepcopy-gen=true +type Expression struct { + // Key is the expression key. + Key string `json:"key"` + // Op is the expression operator. + // +kubebuilder:validation:Enum=Equals;NotEqual;In;NotIn;Exists;NotExist;AlwaysTrue;Matches;MatchesNot;MatchesAny;MatchesNone + // +kubebuilder:validation:Format:string + Op Operator `json:"operator"` + // Values contains the values the key value is evaluated against. + Values []string `json:"values,omitempty"` +} + +// Operator is an expression operator. +type Operator string + +// supported operators +const ( + // Equals tests for equality with a single value. + Equals Operator = "Equals" + // NotEqual test for inequality with a single value. + NotEqual Operator = "NotEqual" + // In tests for any value for the given set. + In Operator = "In" + // NotIn tests for the lack of value in a given set. + NotIn Operator = "NotIn" + // Exists tests if the given key exists with any value. + Exists Operator = "Exists" + // NotExist tests if the given key does not exist. + NotExist Operator = "NotExist" + // AlwaysTrue always evaluates to true. + AlwaysTrue Operator = "AlwaysTrue" + // Matches tests if the key value matches a single globbing pattern. + Matches Operator = "Matches" + // MatchesNot tests if the key value does not match a single globbing pattern. + MatchesNot Operator = "MatchesNot" + // MatchesAny tests if the key value matches any of a set of globbing patterns. + MatchesAny Operator = "MatchesAny" + // MatchesNone tests if the key value matches none of a set of globbing patterns. + MatchesNone Operator = "MatchesNone" +) + +// Keys of supported object properties. +const ( + // Pod of the object. + KeyPod = "pod" + // ID of the object. + KeyID = "id" + // UID of the object. + KeyUID = "uid" + // Name of the object. + KeyName = "name" + // Namespace of the object. + KeyNamespace = "namespace" + // QoSClass of the object. + KeyQOSClass = "qosclass" + // Labels of the object. + KeyLabels = "labels" + // Tags of the object. + KeyTags = "tags" +) diff --git a/pkg/apis/resmgr/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/resmgr/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000..5954e4800 --- /dev/null +++ b/pkg/apis/resmgr/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,41 @@ +//go:build !ignore_autogenerated + +// Copyright The NRI Plugins Authors. All Rights Reserved. +// +// 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. + +// Code generated by controller-gen. DO NOT EDIT. + +package resmgr + +import () + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Expression) DeepCopyInto(out *Expression) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Expression. +func (in *Expression) DeepCopy() *Expression { + if in == nil { + return nil + } + out := new(Expression) + in.DeepCopyInto(out) + return out +}