diff --git a/config/core/resources/trigger.yaml b/config/core/resources/trigger.yaml index 02ff5987093..59187a51810 100644 --- a/config/core/resources/trigger.yaml +++ b/config/core/resources/trigger.yaml @@ -100,6 +100,42 @@ spec: description: 'Attributes filters events by exact match on event context attributes. Each key in the map is compared with the equivalent key in the event context. An event passes the filter if all values are equal to the specified values. Nested context attributes are not supported as keys. Only string values are supported. ' type: object x-kubernetes-preserve-unknown-fields: true + exact: + description: 'Exact evaluates to true if the value of the matching CloudEvents attribute is matches exactly the String value specified (case sensitive). Exact must contain exactly one property, where the key is the name of the CloudEvents attribute to be matched, and its value is the String value to use in the comparison. The attribute name and value specified in the filter express cannot be be empty strings.' + type: object + x-kubernetes-preserve-unknown-fields: true + prefix: + description: 'Prefix evaluates to true if the value of the matching CloudEvents attribute starts with the String value specified (case sensitive). Prefix must contain exactly one property, where the key is the name of the CloudEvents attribute to be matched, and its value is the String value to use in the comparison. The attribute name and value specified in the filter express cannot be be empty strings.' + type: object + x-kubernetes-preserve-unknown-fields: true + suffix: + description: 'Suffix evaluates to true if the value of the matching CloudEvents attribute ends with the String value specified (case sensitive). | Suffix must contain exactly one property, where the key is the name of the CloudEvents attribute to be matched, and its value is the String value to use in the comparison. he attribute name and value specified in the filter express cannot be be empty strings.' + type: object + x-kubernetes-preserve-unknown-fields: true + not: + description: 'Not evaluates to true if the nested expression evaluates to false.' + type: object + # Because kube doesn't allow to use $ref, we can't recursively define this schema. + x-kubernetes-preserve-unknown-fields: true + all: + description: 'All evaluates to true if all the nested expressions evaluate to true. All must contain at least one filter expression.' + type: array + items: + # Because kube doesn't allow to use $ref, we can't recursively reference to the filter schema. + type: object + description: "Sub schema" + x-kubernetes-preserve-unknown-fields: true + any: + description: 'Any evaluates to true if at least one of the nested expressions evaluate to true. Any must contain at least one filter expression.' + type: array + items: + # Because kube doesn't allow to use $ref, we can't recursively reference to the filter schema. + type: object + description: "Sub schema" + x-kubernetes-preserve-unknown-fields: true + # This allows extension filter dialects + additionalProperties: true + x-kubernetes-preserve-unknown-fields: true subscriber: description: Subscriber is the addressable that receives events from the Broker that pass the Filter. It is required. type: object diff --git a/pkg/apis/eventing/v1/trigger_types.go b/pkg/apis/eventing/v1/trigger_types.go index f40e8029299..51b3bb016bd 100644 --- a/pkg/apis/eventing/v1/trigger_types.go +++ b/pkg/apis/eventing/v1/trigger_types.go @@ -79,6 +79,7 @@ type TriggerSpec struct { // Filter is the filter to apply against all events from the Broker. Only events that pass this // filter will be sent to the Subscriber. If not specified, will default to allowing all events. + // If multiple filters are specified, then the same semantics of TriggerFilter.All is applied. // // +optional Filter *TriggerFilter `json:"filter,omitempty"` @@ -92,6 +93,9 @@ type TriggerSpec struct { Delivery *eventingduckv1.DeliverySpec `json:"delivery,omitempty"` } +// TriggerFilter allows to define a filter expression. +// If multiple filters are specified, then the same semantics of TriggerFilter.All is applied. +// If no filter dialect or empty object is specified, then the filter always accept the events. type TriggerFilter struct { // Attributes filters events by exact match on event context attributes. // Each key in the map is compared with the equivalent key in the event @@ -102,6 +106,55 @@ type TriggerFilter struct { // // +optional Attributes TriggerFilterAttributes `json:"attributes,omitempty"` + + // All evaluates to true if all the nested expressions evaluate to true. + // + // All must contain at least one filter expression. + // + // +optional + All []TriggerFilter `json:"all,omitempty"` + + // Any evaluates to true if at least one of the nested expressions evaluate to true. + // + // Any must contain at least one filter expression. + // + // +optional + Any []TriggerFilter `json:"any,omitempty"` + + // Not evaluates to true if the nested expression evaluates to false. + // + // +optional + Not *TriggerFilter `json:"not,omitempty"` + + // Exact evaluates to true if the value of the matching CloudEvents attribute is matches exactly the String value specified (case sensitive). + // Exact must contain exactly one property, where the key is the name of the CloudEvents attribute to be matched, and its value is the String value to use in the comparison. + // + // The attribute name and value specified in the filter express cannot be be empty strings. + // + // +optional + Exact map[string]string `json:"exact,omitempty"` + + // Prefix evaluates to true if the value of the matching CloudEvents attribute starts with the String value specified (case sensitive). + // Prefix must contain exactly one property, where the key is the name of the CloudEvents attribute to be matched, and its value is the String value to use in the comparison. + // + // The attribute name and value specified in the filter express cannot be be empty strings. + // + // +optional + Prefix map[string]string `json:"prefix,omitempty"` + + // Suffix evaluates to true if the value of the matching CloudEvents attribute ends with the String value specified (case sensitive). + // Suffix must contain exactly one property, where the key is the name of the CloudEvents attribute to be matched, and its value is the String value to use in the comparison. + // + // The attribute name and value specified in the filter express cannot be be empty strings. + // + // +optional + Suffix map[string]string `json:"suffix,omitempty"` + + // Extensions includes the list of additional filter dialects supported by specific broker implementations. + // Check out the documentation of the broker implementation you're using to know about what additional filters are supported. + // + // +optional + Extensions map[string]*runtime.RawExtension `json:",inline"` } // TriggerFilterAttributes is a map of context attribute names to values for diff --git a/pkg/apis/eventing/v1/trigger_validation.go b/pkg/apis/eventing/v1/trigger_validation.go index 4b367340a6b..5c92692a33f 100644 --- a/pkg/apis/eventing/v1/trigger_validation.go +++ b/pkg/apis/eventing/v1/trigger_validation.go @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "regexp" + "strings" "knative.dev/pkg/apis" "knative.dev/pkg/kmp" @@ -53,16 +54,8 @@ func (ts *TriggerSpec) Validate(ctx context.Context) *apis.FieldError { errs = errs.Also(fe) } - if ts.Filter != nil { - for attr := range map[string]string(ts.Filter.Attributes) { - if !validAttributeName.MatchString(attr) { - fe := &apis.FieldError{ - Message: fmt.Sprintf("Invalid attribute name: %q", attr), - Paths: []string{"filter.attributes"}, - } - errs = errs.Also(fe) - } - } + for _, err := range validateFilterSpec(ts.Filter, []string{"filter"}) { + errs = errs.Also(err) } if fe := ts.Subscriber.Validate(ctx); fe != nil { @@ -78,6 +71,113 @@ func (ts *TriggerSpec) Validate(ctx context.Context) *apis.FieldError { return errs } +func validateFilterSpec(filter *TriggerFilter, path []string) (errs []*apis.FieldError) { + if filter == nil { + return nil + } + + // Validate Attributes + for attr := range map[string]string(filter.Attributes) { + if !validAttributeName.MatchString(attr) { + errs = append(errs, &apis.FieldError{ + Message: fmt.Sprintf("Invalid attribute name: %q", attr), + Paths: []string{strings.Join(append(path, "attributes"), ".")}, + }) + } + } + + // Validate Exact + if filter.Exact != nil { + if len(filter.Exact) != 1 { + errs = append(errs, &apis.FieldError{ + Message: "Exact can have only one key-value", + Paths: []string{strings.Join(append(path, "exact"), ".")}, + }) + } + for attr := range filter.Exact { + if !validAttributeName.MatchString(attr) { + errs = append(errs, &apis.FieldError{ + Message: fmt.Sprintf("Invalid attribute name: %q", attr), + Paths: []string{strings.Join(append(path, "exact"), ".")}, + }) + } + } + } + + // Validate Prefix + if filter.Prefix != nil { + if len(filter.Prefix) != 1 { + errs = append(errs, &apis.FieldError{ + Message: "Prefix can have only one key-value", + Paths: []string{strings.Join(append(path, "prefix"), ".")}, + }) + } + for attr := range filter.Prefix { + if !validAttributeName.MatchString(attr) { + errs = append(errs, &apis.FieldError{ + Message: fmt.Sprintf("Invalid attribute name: %q", attr), + Paths: []string{strings.Join(append(path, "prefix"), ".")}, + }) + } + } + } + + // Validate Suffix + if filter.Suffix != nil { + if len(filter.Suffix) != 1 { + errs = append(errs, &apis.FieldError{ + Message: "Suffix can have only one key-value", + Paths: []string{strings.Join(append(path, "suffix"), ".")}, + }) + } + for attr := range filter.Suffix { + if !validAttributeName.MatchString(attr) { + errs = append(errs, &apis.FieldError{ + Message: fmt.Sprintf("Invalid attribute name: %q", attr), + Paths: []string{strings.Join(append(path, "suffix"), ".")}, + }) + } + } + } + + // Validate All + if filter.All != nil { + if len(filter.All) < 1 { + errs = append(errs, &apis.FieldError{ + Message: "All must contain at least one nested filter", + Paths: []string{strings.Join(append(path, "all"), ".")}, + }) + } + + for i, f := range filter.All { + f := f + errs = append(errs, validateFilterSpec(&f, append(path, "all", fmt.Sprintf("[%d]", i)))...) + } + } + + // Validate Any + if filter.Any != nil { + if len(filter.Any) < 1 { + errs = append(errs, &apis.FieldError{ + Message: "Any must contain at least one nested filter", + Paths: []string{strings.Join(append(path, "any"), ".")}, + }) + } + + for i, f := range filter.Any { + f := f + errs = append(errs, validateFilterSpec(&f, append(path, "any", fmt.Sprintf("[%d]", i)))...) + } + } + + // Validate Not + if filter.Not != nil { + errs = append(errs, validateFilterSpec(filter.Not, append(path, "not"))...) + } + + return +} + // CheckImmutableFields checks that any immutable fields were not changed. func (t *Trigger) CheckImmutableFields(ctx context.Context, original *Trigger) *apis.FieldError { if original == nil { diff --git a/pkg/apis/eventing/v1/trigger_validation_test.go b/pkg/apis/eventing/v1/trigger_validation_test.go index 4dad96e5e89..10dc180ebd5 100644 --- a/pkg/apis/eventing/v1/trigger_validation_test.go +++ b/pkg/apis/eventing/v1/trigger_validation_test.go @@ -23,9 +23,11 @@ import ( "github.com/google/go-cmp/cmp" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - eventingduckv1 "knative.dev/eventing/pkg/apis/duck/v1" + "k8s.io/apimachinery/pkg/runtime" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" + + eventingduckv1 "knative.dev/eventing/pkg/apis/duck/v1" ) var ( @@ -473,6 +475,254 @@ func TestTriggerSpecValidation(t *testing.T) { } } +func TestFilterSpecValidation(t *testing.T) { + tests := []struct { + name string + filter *TriggerFilter + want *apis.FieldError + }{{ + name: "missing attributes keys, match all", + filter: &TriggerFilter{ + Attributes: TriggerFilterAttributes{}, + }, + want: &apis.FieldError{}, + }, { + name: "invalid attribute name, start with number", + filter: &TriggerFilter{ + Attributes: TriggerFilterAttributes{ + "0invalid": "my-value", + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "0invalid"`, + Paths: []string{"filter.attributes"}, + }, + }, { + name: "invalid attribute name, capital letters", + filter: &TriggerFilter{ + Attributes: TriggerFilterAttributes{ + "invALID": "my-value", + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "invALID"`, + Paths: []string{"filter.attributes"}, + }, + }, { + name: "valid empty filter", + filter: validEmptyFilter, + want: &apis.FieldError{}, + }, { + name: "valid SourceAndType filter", + filter: validAttributesFilter, + want: &apis.FieldError{}, + }, { + name: "valid Attributes filter", + filter: validAttributesFilter, + want: &apis.FieldError{}, + }, { + name: "exact filter contains more than one attribute", + filter: &TriggerFilter{ + Exact: map[string]string{ + "myext": "abc", + "anotherext": "xyz", + }, + }, + want: &apis.FieldError{ + Message: `Exact can have only one key-value`, + Paths: []string{"filter.exact"}, + }, + }, { + name: "exact filter contains invalid attribute name", + filter: &TriggerFilter{ + Exact: map[string]string{ + "invALID": "abc", + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "invALID"`, + Paths: []string{"filter.exact"}, + }, + }, { + name: "valid exact filter", + filter: &TriggerFilter{ + Exact: map[string]string{ + "valid": "abc", + }, + }, + want: &apis.FieldError{}, + }, { + name: "suffix filter contains more than one attribute", + filter: &TriggerFilter{ + Suffix: map[string]string{ + "myext": "abc", + "anotherext": "xyz", + }, + }, + want: &apis.FieldError{ + Message: `Suffix can have only one key-value`, + Paths: []string{"filter.suffix"}, + }, + }, { + name: "suffix filter contains invalid attribute name", + filter: &TriggerFilter{ + Suffix: map[string]string{ + "invALID": "abc", + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "invALID"`, + Paths: []string{"filter.suffix"}, + }, + }, { + name: "valid suffix filter", + filter: &TriggerFilter{ + Suffix: map[string]string{ + "valid": "abc", + }, + }, + want: &apis.FieldError{}, + }, { + name: "prefix filter contains more than one attribute", + filter: &TriggerFilter{ + Prefix: map[string]string{ + "myext": "abc", + "anotherext": "xyz", + }, + }, + want: &apis.FieldError{ + Message: `Prefix can have only one key-value`, + Paths: []string{"filter.prefix"}, + }, + }, { + name: "prefix filter contains invalid attribute name", + filter: &TriggerFilter{ + Prefix: map[string]string{ + "invALID": "abc", + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "invALID"`, + Paths: []string{"filter.prefix"}, + }, + }, { + name: "valid prefix filter", + filter: &TriggerFilter{ + Prefix: map[string]string{ + "valid": "abc", + }, + }, + want: &apis.FieldError{}, + }, { + name: "not nested expression is valid", + filter: &TriggerFilter{ + Not: validAttributesFilter, + }, + want: &apis.FieldError{}, + }, { + name: "not nested expression is invalid", + filter: &TriggerFilter{ + Not: &TriggerFilter{ + Prefix: map[string]string{ + "invALID": "abc", + }, + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "invALID"`, + Paths: []string{"filter.not.prefix"}, + }, + }, { + name: "all filter is empty", + filter: &TriggerFilter{ + All: []TriggerFilter{}, + }, + want: &apis.FieldError{ + Message: `All must contain at least one nested filter`, + Paths: []string{"filter.all"}, + }, + }, { + name: "all filter is valid", + filter: &TriggerFilter{ + All: []TriggerFilter{ + *validAttributesFilter, + {Exact: map[string]string{"myattr": "myval"}}, + }, + }, + want: &apis.FieldError{}, + }, { + name: "all filter sub expression is invalid", + filter: &TriggerFilter{ + All: []TriggerFilter{ + *validAttributesFilter, + {Exact: map[string]string{"myattr": "myval"}}, + {Prefix: map[string]string{ + "invALID": "abc", + }}, + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "invALID"`, + Paths: []string{"filter.all.[2].prefix"}, + }, + }, { + name: "any filter is empty", + filter: &TriggerFilter{ + Any: []TriggerFilter{}, + }, + want: &apis.FieldError{ + Message: `Any must contain at least one nested filter`, + Paths: []string{"filter.any"}, + }, + }, { + name: "any filter is valid", + filter: &TriggerFilter{ + Any: []TriggerFilter{ + *validAttributesFilter, + {Exact: map[string]string{"myattr": "myval"}}, + }, + }, + want: &apis.FieldError{}, + }, { + name: "any filter sub expression is invalid", + filter: &TriggerFilter{ + Any: []TriggerFilter{ + *validAttributesFilter, + {Exact: map[string]string{"myattr": "myval"}}, + {Prefix: map[string]string{ + "invALID": "abc", + }}, + }, + }, + want: &apis.FieldError{ + Message: `Invalid attribute name: "invALID"`, + Paths: []string{"filter.any.[2].prefix"}, + }, + }, { + name: "raw extension expression is valid", + filter: &TriggerFilter{ + Extensions: map[string]*runtime.RawExtension{ + "juel": {Raw: []byte("\"myExpressionUsingJUEL\"")}, + }, + }, + want: &apis.FieldError{}, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ts := &TriggerSpec{ + Broker: "test_broker", + Filter: test.filter, + Subscriber: validSubscriber, + } + got := ts.Validate(context.TODO()) + if diff := cmp.Diff(test.want.Error(), got.Error()); diff != "" { + t.Errorf("Validate TriggerSpec (-want, +got) =\n%s", diff) + } + }) + } +} + func TestTriggerImmutableFields(t *testing.T) { tests := []struct { name string diff --git a/pkg/apis/eventing/v1/zz_generated.deepcopy.go b/pkg/apis/eventing/v1/zz_generated.deepcopy.go index c1f4be24871..cbdd7cb8780 100644 --- a/pkg/apis/eventing/v1/zz_generated.deepcopy.go +++ b/pkg/apis/eventing/v1/zz_generated.deepcopy.go @@ -170,6 +170,61 @@ func (in *TriggerFilter) DeepCopyInto(out *TriggerFilter) { (*out)[key] = val } } + if in.All != nil { + in, out := &in.All, &out.All + *out = make([]TriggerFilter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Any != nil { + in, out := &in.Any, &out.Any + *out = make([]TriggerFilter, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Not != nil { + in, out := &in.Not, &out.Not + *out = new(TriggerFilter) + (*in).DeepCopyInto(*out) + } + if in.Exact != nil { + in, out := &in.Exact, &out.Exact + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Prefix != nil { + in, out := &in.Prefix, &out.Prefix + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Suffix != nil { + in, out := &in.Suffix, &out.Suffix + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Extensions != nil { + in, out := &in.Extensions, &out.Extensions + *out = make(map[string]*runtime.RawExtension, len(*in)) + for key, val := range *in { + var outVal *runtime.RawExtension + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } + (*out)[key] = outVal + } + } return }