diff --git a/.github/workflows/kind-e2e.yaml b/.github/workflows/kind-e2e.yaml index 31f5380dcf1..8eea1965905 100644 --- a/.github/workflows/kind-e2e.yaml +++ b/.github/workflows/kind-e2e.yaml @@ -29,6 +29,7 @@ jobs: - ./test/rekt/... - ./test/e2e - ./test/conformance + - ./test/experimental # Map between K8s and KinD versions. # This is attempting to make it a bit clearer what's being tested. diff --git a/config/400-config-experimental-features.yaml b/config/400-config-experimental-features.yaml new file mode 120000 index 00000000000..512caf4da6a --- /dev/null +++ b/config/400-config-experimental-features.yaml @@ -0,0 +1 @@ +core/configmaps/experimental-features.yaml \ No newline at end of file diff --git a/config/core/configmaps/experimental-features.yaml b/config/core/configmaps/experimental-features.yaml new file mode 100644 index 00000000000..cd44c522877 --- /dev/null +++ b/config/core/configmaps/experimental-features.yaml @@ -0,0 +1,24 @@ +# Copyright 2021 The Knative 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 +# +# https://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. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: config-experimental-features + namespace: knative-eventing + labels: + eventing.knative.dev/release: devel + knative.dev/config-propagation: original + knative.dev/config-category: eventing +data: diff --git a/pkg/apis/experimental/api_validation.go b/pkg/apis/experimental/api_validation.go new file mode 100644 index 00000000000..46e36455310 --- /dev/null +++ b/pkg/apis/experimental/api_validation.go @@ -0,0 +1,85 @@ +/* +Copyright 2021 The Knative 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 experimental + +import ( + "context" + "fmt" + "reflect" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" +) + +// ValidateAPIFields checks that the experimental features fields are disabled if the experimental flag is disabled. +// experimentalFields can contain a string with dots, to identify sub-structs, like "Destination.Ref.APIVersion" +func ValidateAPIFields(ctx context.Context, featureName string, object interface{}, experimentalFields ...string) (errs *apis.FieldError) { + obj := reflect.ValueOf(object) + obj = reflect.Indirect(obj) + if obj.Kind() != reflect.Struct { + return nil + } + + // If feature not enabled, let's check the field is not used + if !FromContext(ctx).IsEnabled(featureName) { + for _, fieldName := range experimentalFields { + fieldVal := walk(obj, strings.Split(fieldName, ".")...) + + if !fieldVal.IsZero() { + errs = errs.Also(&apis.FieldError{ + Message: fmt.Sprintf("Disallowed field because the experimental feature '%s' is disabled", featureName), + Paths: []string{fmt.Sprintf("%s.%s", obj.Type().Name(), fieldName)}, + }) + } + } + } + + return errs +} + +// ValidateAnnotations checks that the experimental features annotations are disabled if the experimental flag is disabled +func ValidateAnnotations(ctx context.Context, featureName string, object metav1.Object, experimentalAnnotations ...string) (errs *apis.FieldError) { + // If feature not enabled, let's check the annotation is not used + if !FromContext(ctx).IsEnabled(featureName) { + for _, annotation := range experimentalAnnotations { + if _, ok := object.GetAnnotations()[annotation]; ok { + errs = errs.Also(&apis.FieldError{ + Message: fmt.Sprintf("Disallowed annotation because the experimental feature '%s' is disabled", featureName), + Paths: []string{annotation}, + }) + } + } + } + + return errs +} + +func walk(value reflect.Value, paths ...string) reflect.Value { + switch value.Kind() { + case reflect.Struct: + newVal := value.FieldByName(paths[0]) + if len(paths) == 1 { + return newVal + } + return walk(value.FieldByName(paths[0]), paths[1:]...) + case reflect.Ptr: + return walk(reflect.Indirect(value), paths...) + default: + return reflect.Zero(value.Type()) + } +} diff --git a/pkg/apis/experimental/api_validation_test.go b/pkg/apis/experimental/api_validation_test.go new file mode 100644 index 00000000000..1e2320a8653 --- /dev/null +++ b/pkg/apis/experimental/api_validation_test.go @@ -0,0 +1,205 @@ +/* +Copyright 2021 The Knative 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 experimental + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" + duckv1 "knative.dev/pkg/apis/duck/v1" + + eventingv1 "knative.dev/eventing/pkg/apis/eventing/v1" +) + +const flagName = "my-flag" + +func TestValidateAPIFields(t *testing.T) { + tests := []struct { + name string + flags Flags + featureName string + object interface{} + experimentalFields []string + wantErrs *apis.FieldError + }{ + { + name: "invalid input", + featureName: flagName, + flags: map[string]bool{ + flagName: true, + }, + object: []string{}, + experimentalFields: []string{"Filter"}, + }, + { + name: "enabled flag", + featureName: flagName, + flags: map[string]bool{ + flagName: true, + }, + object: eventingv1.TriggerSpec{ + Broker: "blabla", + Subscriber: duckv1.Destination{ + URI: apis.HTTP("example.com"), + }, + Filter: &eventingv1.TriggerFilter{}, + }, + experimentalFields: []string{"Filter"}, + }, + { + name: "disabled pointer flag", + featureName: flagName, + flags: map[string]bool{ + flagName: false, + }, + object: eventingv1.TriggerSpec{ + Broker: "blabla", + Subscriber: duckv1.Destination{ + URI: apis.HTTP("example.com"), + }, + Filter: &eventingv1.TriggerFilter{}, + }, + experimentalFields: []string{"Filter"}, + wantErrs: &apis.FieldError{ + Message: fmt.Sprintf("Disallowed field because the experimental feature '%s' is disabled", flagName), + Paths: []string{"TriggerSpec.Filter"}, + }, + }, + { + name: "disabled nested string flag", + featureName: flagName, + flags: map[string]bool{ + flagName: false, + }, + object: eventingv1.TriggerSpec{ + Broker: "blabla", + Subscriber: duckv1.Destination{ + Ref: &duckv1.KReference{ + Namespace: "abc", + }, + }, + }, + experimentalFields: []string{"Subscriber.Ref.Namespace"}, + wantErrs: &apis.FieldError{ + Message: fmt.Sprintf("Disallowed field because the experimental feature '%s' is disabled", flagName), + Paths: []string{"Subscriber.Ref.Namespace"}, + }, + }, + { + name: "enabled nested string flag", + featureName: flagName, + flags: map[string]bool{ + flagName: true, + }, + object: eventingv1.TriggerSpec{ + Broker: "blabla", + Subscriber: duckv1.Destination{ + Ref: &duckv1.KReference{ + Namespace: "abc", + }, + }, + }, + experimentalFields: []string{"Subscriber.Ref.Namespace"}, + }, + { + name: "disabled map flag", + featureName: flagName, + flags: map[string]bool{ + flagName: false, + }, + object: &eventingv1.TriggerFilter{ + Attributes: map[string]string{}, + }, + experimentalFields: []string{"Attributes"}, + wantErrs: &apis.FieldError{ + Message: fmt.Sprintf("Disallowed field because the experimental feature '%s' is disabled", flagName), + Paths: []string{"TriggerFilter.Attributes"}, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := ToContext(context.Background(), tt.flags) + + res := ValidateAPIFields(ctx, tt.featureName, tt.object, tt.experimentalFields...) + if tt.wantErrs == nil { + require.Nil(t, res) + } else { + require.Error(t, res, tt.wantErrs.Error()) + } + }) + } +} + +func TestValidateAnnotations(t *testing.T) { + tests := []struct { + name string + flags Flags + featureName string + object metav1.Object + experimentalFields []string + wantErrs *apis.FieldError + }{{ + name: "enabled flag", + featureName: flagName, + flags: map[string]bool{ + flagName: true, + }, + object: &eventingv1.Broker{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "dev.knative/myfancyannotation": "blabla", + }, + }, + }, + experimentalFields: []string{"dev.knative/myfancyannotation"}, + }, + { + name: "disabled flag", + featureName: flagName, + flags: map[string]bool{ + flagName: false, + }, + object: &eventingv1.Broker{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "dev.knative/myfancyannotation": "blabla", + }, + }, + }, + experimentalFields: []string{"dev.knative/myfancyannotation"}, + wantErrs: &apis.FieldError{ + Message: fmt.Sprintf("Disallowed field because the experimental feature '%s' is disabled", flagName), + Paths: []string{"dev.knative/myfancyannotation"}, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := ToContext(context.Background(), tt.flags) + + res := ValidateAnnotations(ctx, tt.featureName, tt.object, tt.experimentalFields...) + if tt.wantErrs == nil { + require.Nil(t, res) + } else { + require.Error(t, res, tt.wantErrs.Error()) + } + }) + } +} diff --git a/pkg/apis/experimental/features.go b/pkg/apis/experimental/features.go new file mode 100644 index 00000000000..b10ce3e1990 --- /dev/null +++ b/pkg/apis/experimental/features.go @@ -0,0 +1,60 @@ +/* +Copyright 2021 The Knative 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 experimental + +import ( + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +// Flags is a map containing all the enabled/disabled flags for the experimental features. +// Missing entry in the map means feature is equal to feature not enabled. +type Flags map[string]bool + +// IsEnabled returns true if the feature is enabled +func (e Flags) IsEnabled(featureName string) bool { + return e != nil && e[featureName] +} + +// NewFlagsConfigFromMap creates a Flags from the supplied Map +func NewFlagsConfigFromMap(data map[string]string) (Flags, error) { + flags := Flags{} + + for k, v := range data { + if strings.HasPrefix(k, "_") { + // Ignore all the keys starting with _ + continue + } + sanitizedKey := strings.TrimSpace(k) + if strings.EqualFold(v, "true") { + flags[sanitizedKey] = true + } else if strings.EqualFold(v, "false") { + flags[sanitizedKey] = false + } else { + return Flags{}, fmt.Errorf("cannot parse the boolean flag '%s' = '%s'. Allowed values: [true, false]", k, v) + } + } + + return flags, nil +} + +// NewFlagsConfigFromConfigMap creates a Flags from the supplied configMap +func NewFlagsConfigFromConfigMap(config *corev1.ConfigMap) (Flags, error) { + return NewFlagsConfigFromMap(config.Data) +} diff --git a/pkg/apis/experimental/features_test.go b/pkg/apis/experimental/features_test.go new file mode 100644 index 00000000000..738c8809ac0 --- /dev/null +++ b/pkg/apis/experimental/features_test.go @@ -0,0 +1,52 @@ +/* +Copyright 2021 The Knative 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 experimental + +import ( + "testing" + + "github.com/stretchr/testify/require" + . "knative.dev/pkg/configmap/testing" + _ "knative.dev/pkg/system/testing" +) + +func TestFlags_IsEnabled_NilMap(t *testing.T) { + require.False(t, Flags(nil).IsEnabled("myflag")) +} + +func TestFlags_IsEnabled_EmptyMap(t *testing.T) { + require.False(t, Flags{}.IsEnabled("myflag")) +} + +func TestFlags_IsEnabled_ContainingFlag(t *testing.T) { + require.True(t, Flags{ + "myflag": true, + }.IsEnabled("myflag")) + require.False(t, Flags{ + "myflag": false, + }.IsEnabled("myflag")) +} + +func TestGetFlags(t *testing.T) { + _, example := ConfigMapsFromTestFile(t, FlagsConfigName) + flags, err := NewFlagsConfigFromConfigMap(example) + require.NoError(t, err) + + require.True(t, flags.IsEnabled("my-true-flag")) + require.False(t, flags.IsEnabled("my-false-flag")) + require.False(t, flags.IsEnabled("non-existing-flag")) +} diff --git a/pkg/apis/experimental/store.go b/pkg/apis/experimental/store.go new file mode 100644 index 00000000000..20aacf7d10c --- /dev/null +++ b/pkg/apis/experimental/store.go @@ -0,0 +1,95 @@ +/* +Copyright 2021 The Knative 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 experimental + +import ( + "context" + + "knative.dev/pkg/configmap" +) + +const ( + // FlagsConfigName is the name of config map containing the experimental features flags + FlagsConfigName = "config-experimental-features" +) + +type cfgKey struct{} + +// FromContext extracts a Config from the provided context. +func FromContext(ctx context.Context) Flags { + x, ok := ctx.Value(cfgKey{}).(Flags) + if ok { + return x + } + return nil +} + +// FromContextOrDefaults is like FromContext, but when no Flags is attached it +// returns an empty Flags. +func FromContextOrDefaults(ctx context.Context) Flags { + if cfg := FromContext(ctx); cfg != nil { + return cfg + } + return Flags{} +} + +// ToContext attaches the provided Flags to the provided context, returning the +// new context with the Flags attached. +func ToContext(ctx context.Context, c Flags) context.Context { + return context.WithValue(ctx, cfgKey{}, c) +} + +// Store is a typed wrapper around configmap.Untyped store to handle our configmaps. +// +k8s:deepcopy-gen=false +type Store struct { + *configmap.UntypedStore +} + +// NewStore creates a new store of Configs and optionally calls functions when ConfigMaps are updated. +func NewStore(logger configmap.Logger, onAfterStore ...func(name string, value interface{})) *Store { + store := &Store{ + UntypedStore: configmap.NewUntypedStore( + "experimental-flags", + logger, + configmap.Constructors{ + FlagsConfigName: NewFlagsConfigFromConfigMap, + }, + onAfterStore..., + ), + } + + return store +} + +// ToContext attaches the current Config state to the provided context. +func (s *Store) ToContext(ctx context.Context) context.Context { + return ToContext(ctx, s.Load()) +} + +// IsEnabled is a shortcut for Load().IsEnabled(featureName) +func (s *Store) IsEnabled(featureName string) bool { + return s.Load().IsEnabled(featureName) +} + +// Load creates a Config from the current config state of the Store. +func (s *Store) Load() Flags { + loaded := s.UntypedLoad(FlagsConfigName) + if loaded == nil { + return Flags(nil) + } + return loaded.(Flags) +} diff --git a/pkg/apis/experimental/store_test.go b/pkg/apis/experimental/store_test.go new file mode 100644 index 00000000000..53456314ee2 --- /dev/null +++ b/pkg/apis/experimental/store_test.go @@ -0,0 +1,53 @@ +/* +Copyright 2021 The Knative 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 experimental + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + logtesting "knative.dev/pkg/logging/testing" + + . "knative.dev/pkg/configmap/testing" +) + +func TestStoreLoadWithContext(t *testing.T) { + store := NewStore(logtesting.TestLogger(t)) + + _, exampleConfig := ConfigMapsFromTestFile(t, FlagsConfigName) + store.OnConfigChanged(exampleConfig) + + have := FromContextOrDefaults(store.ToContext(context.Background())) + expected, _ := NewFlagsConfigFromConfigMap(exampleConfig) + + require.Equal(t, expected.IsEnabled("my-true-flag"), have.IsEnabled("my-true-flag")) + require.Equal(t, expected.IsEnabled("my-false-flag"), have.IsEnabled("my-false-flag")) + require.Equal(t, expected.IsEnabled("other-flag"), have.IsEnabled("other-flag")) + + require.Equal(t, expected.IsEnabled("my-true-flag"), store.IsEnabled("my-true-flag")) + require.Equal(t, expected.IsEnabled("my-false-flag"), store.IsEnabled("my-false-flag")) + require.Equal(t, expected.IsEnabled("other-flag"), store.IsEnabled("other-flag")) +} + +func TestStoreLoadWithContextOrDefaults(t *testing.T) { + have := FromContextOrDefaults(context.Background()) + + require.False(t, have.IsEnabled("my-true-flag")) + require.False(t, have.IsEnabled("my-false-flag")) + require.False(t, have.IsEnabled("other-flag")) +} diff --git a/pkg/apis/experimental/testdata/config-experimental-features.yaml b/pkg/apis/experimental/testdata/config-experimental-features.yaml new file mode 100644 index 00000000000..d6392db2817 --- /dev/null +++ b/pkg/apis/experimental/testdata/config-experimental-features.yaml @@ -0,0 +1,27 @@ +# Copyright 2020 The Knative 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 +# +# https://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. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: config-experimental-features + namespace: knative-eventing + labels: + eventing.knative.dev/release: devel + knative.dev/config-propagation: original + knative.dev/config-category: eventing +data: + _example: | + my-true-flag: "true" + my-false-flag: "false" diff --git a/test/experimental/main_test.go b/test/experimental/main_test.go new file mode 100644 index 00000000000..9177375fc94 --- /dev/null +++ b/test/experimental/main_test.go @@ -0,0 +1,86 @@ +// +build e2e + +/* +Copyright 2021 The Knative 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 experimental + +import ( + "flag" + "os" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/eventing/pkg/apis/experimental" + "knative.dev/pkg/system" + + // Uncomment the following line to load the gcp plugin (only required to authenticate against GKE clusters). + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" + kubeclient "knative.dev/pkg/client/injection/kube/client" + "knative.dev/pkg/injection" + _ "knative.dev/pkg/system/testing" + + "knative.dev/reconciler-test/pkg/environment" +) + +// global is the singleton instance of GlobalEnvironment. It is used to parse +// the testing config for the test run. The config will specify the cluster +// config as well as the parsing level and state flags. +var global environment.GlobalEnvironment + +func init() { + // environment.InitFlags registers state and level filter flags. + environment.InitFlags(flag.CommandLine) +} + +// TestMain is the first entry point for `go test`. +func TestMain(m *testing.M) { + // We get a chance to parse flags to include the framework flags for the + // framework as well as any additional flags included in the integration. + flag.Parse() + + // EnableInjectionOrDie will enable client injection, this is used by the + // testing framework for namespace management, and could be leveraged by + // features to pull Kubernetes clients or the test environment out of the + // context passed in the features. + ctx, startInformers := injection.EnableInjectionOrDie(nil, nil) //nolint + startInformers() + + // global is used to make instances of Environments, NewGlobalEnvironment + // is passing and saving the client injection enabled context for use later. + global = environment.NewGlobalEnvironment(ctx) + + // -- Setup the experimental features CM -- + experimentalFeaturesCm, err := kubeclient.Get(ctx). + CoreV1(). + ConfigMaps(system.Namespace()). + Get(ctx, experimental.FlagsConfigName, metav1.GetOptions{}) + if err != nil { + panic("Cannot retrieve the experimental features config map") + } + + // Enable the experimental features to test + _, err = kubeclient.Get(ctx). + CoreV1(). + ConfigMaps(system.Namespace()). + Update(ctx, experimentalFeaturesCm, metav1.UpdateOptions{}) + if err != nil { + panic("Cannot update the experimental features config map") + } + + // Run the tests. + os.Exit(m.Run()) +}