From 0cad1a89b6721308746cc1a12f12de31a259a0d3 Mon Sep 17 00:00:00 2001 From: Ben Luddy Date: Wed, 23 Oct 2024 16:36:25 -0400 Subject: [PATCH] Wire test-only feature gate for CBOR serving. To mitigate the risk of introducing a new protocol, integration tests for CBOR will be written using a test-only feature gate instance that is not wired to runtime options. On alpha graduation, the test-only feature gate instance will be replaced by a normal feature gate in the existing apiserver feature gate instance. --- .../pkg/apiserver/customresource_handler.go | 28 ++- .../test/integration/cbor_test.go | 236 ++++++++++++++++++ .../apiserver/pkg/features/kube_features.go | 19 ++ .../src/k8s.io/apiserver/pkg/server/config.go | 6 +- .../apiserver/pkg/server/config_test.go | 22 ++ .../pkg/util/feature/feature_gate.go | 12 + .../test_data/versioned_feature_list.yaml | 6 + test/integration/framework/cbor.go | 70 ++++++ 8 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 staging/src/k8s.io/apiextensions-apiserver/test/integration/cbor_test.go create mode 100644 test/integration/framework/cbor.go diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go index 1c27ebf730d..be1076d7912 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go @@ -54,6 +54,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/runtime/serializer/cbor" "k8s.io/apimachinery/pkg/runtime/serializer/json" "k8s.io/apimachinery/pkg/runtime/serializer/protobuf" "k8s.io/apimachinery/pkg/runtime/serializer/versioning" @@ -69,8 +70,10 @@ import ( "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" "k8s.io/apiserver/pkg/endpoints/metrics" apirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/features" "k8s.io/apiserver/pkg/registry/generic" genericfilters "k8s.io/apiserver/pkg/server/filters" + utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/apiserver/pkg/util/webhook" "k8s.io/apiserver/pkg/warning" "k8s.io/client-go/scale" @@ -600,6 +603,20 @@ func (r *crdHandler) GetCustomResourceListerCollectionDeleter(crd *apiextensions return info.storages[info.storageVersion].CustomResource, nil } +func newCBORSerializerInfo(creater runtime.ObjectCreater, typer runtime.ObjectTyper) runtime.SerializerInfo { + return runtime.SerializerInfo{ + MediaType: "application/cbor", + MediaTypeType: "application", + MediaTypeSubType: "cbor", + Serializer: cbor.NewSerializer(creater, typer), + StrictSerializer: cbor.NewSerializer(creater, typer, cbor.Strict(true)), + StreamSerializer: &runtime.StreamSerializerInfo{ + Framer: cbor.NewFramer(), + Serializer: cbor.NewSerializer(creater, typer, cbor.Transcode(false)), + }, + } +} + // getOrCreateServingInfoFor gets the CRD serving info for the given CRD UID if the key exists in the storage map. // Otherwise the function fetches the up-to-date CRD using the given CRD name and creates CRD serving info. func (r *crdHandler) getOrCreateServingInfoFor(uid types.UID, name string) (*crdInfo, error) { @@ -892,6 +909,11 @@ func (r *crdHandler) getOrCreateServingInfoFor(uid types.UID, name string) (*crd }, }, } + + if utilfeature.TestOnlyFeatureGate.Enabled(features.TestOnlyCBORServingAndStorage) { + negotiatedSerializer.supportedMediaTypes = append(negotiatedSerializer.supportedMediaTypes, newCBORSerializerInfo(creator, typer)) + } + var standardSerializers []runtime.SerializerInfo for _, s := range negotiatedSerializer.SupportedMediaTypes() { if s.MediaType == runtime.ContentTypeProtobuf { @@ -955,7 +977,11 @@ func (r *crdHandler) getOrCreateServingInfoFor(uid types.UID, name string) (*crd scaleScope := *requestScopes[v.Name] scaleConverter := scale.NewScaleConverter() scaleScope.Subresource = "scale" - scaleScope.Serializer = serializer.NewCodecFactory(scaleConverter.Scheme()) + var opts []serializer.CodecFactoryOptionsMutator + if utilfeature.TestOnlyFeatureGate.Enabled(features.TestOnlyCBORServingAndStorage) { + opts = append(opts, serializer.WithSerializer(newCBORSerializerInfo)) + } + scaleScope.Serializer = serializer.NewCodecFactory(scaleConverter.Scheme(), opts...) scaleScope.Kind = autoscalingv1.SchemeGroupVersion.WithKind("Scale") scaleScope.Namer = handlers.ContextBasedNaming{ Namer: meta.NewAccessor(), diff --git a/staging/src/k8s.io/apiextensions-apiserver/test/integration/cbor_test.go b/staging/src/k8s.io/apiextensions-apiserver/test/integration/cbor_test.go new file mode 100644 index 00000000000..fe6dc695098 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/test/integration/cbor_test.go @@ -0,0 +1,236 @@ +/* +Copyright 2024 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 integration + +import ( + "context" + "fmt" + "testing" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/apiextensions-apiserver/test/integration/fixtures" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + cbor "k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct" + "k8s.io/apiserver/pkg/features" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/retry" + featuregatetesting "k8s.io/component-base/featuregate/testing" +) + +func TestCBORServingEnablement(t *testing.T) { + for _, tc := range []struct { + name string + enabled bool + }{ + {name: "enabled", enabled: true}, + {name: "disabled", enabled: false}, + } { + t.Run(tc.name, func(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.TestOnlyFeatureGate, features.TestOnlyCBORServingAndStorage, tc.enabled) + + tearDown, config, _, err := fixtures.StartDefaultServer(t) + if err != nil { + t.Fatal(err) + } + defer tearDown() + + apiExtensionsClientset, err := apiextensionsclientset.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + + crd := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "foos.mygroup.example.com"}, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Group: "mygroup.example.com", + Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{ + Name: "v1beta1", + Served: true, + Storage: true, + Schema: fixtures.AllowAllSchema(), + Subresources: &apiextensionsv1.CustomResourceSubresources{ + Status: &apiextensionsv1.CustomResourceSubresourceStatus{}, + Scale: &apiextensionsv1.CustomResourceSubresourceScale{ + SpecReplicasPath: ".spec.replicas", + StatusReplicasPath: ".status.replicas", + }, + }, + }}, + Names: apiextensionsv1.CustomResourceDefinitionNames{ + Plural: "foos", + Singular: "foo", + Kind: "Foo", + ListKind: "FooList", + }, + Scope: apiextensionsv1.ClusterScoped, + }, + } + if _, err = fixtures.CreateNewV1CustomResourceDefinition(crd, apiExtensionsClientset, dynamicClient); err != nil { + t.Fatal(err) + } + cr, err := dynamicClient.Resource(schema.GroupVersionResource{Group: "mygroup.example.com", Version: "v1beta1", Resource: "foos"}).Create( + context.TODO(), + &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "mygroup.example.com/v1beta1", + "kind": "Foo", + "metadata": map[string]interface{}{ + "name": fmt.Sprintf("test-cbor-%s", tc.name), + }, + "spec": map[string]interface{}{ + "replicas": int64(0), + }, + "status": map[string]interface{}{ + "replicas": int64(0), + }, + }}, + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + config = rest.CopyConfig(config) + config.NegotiatedSerializer = serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion() + config.APIPath = "/apis" + config.GroupVersion = &schema.GroupVersion{Group: "mygroup.example.com", Version: "v1beta1"} + restClient, err := rest.RESTClientFor(config) + if err != nil { + t.Fatal(err) + } + + for _, subresource := range []string{"", "status", "scale"} { + err = restClient.Get(). + Resource(crd.Spec.Names.Plural). + SubResource(subresource). + Name(cr.GetName()). + SetHeader("Accept", "application/cbor"). + Do(context.TODO()).Error() + switch { + case tc.enabled && err == nil: + // ok + case !tc.enabled && errors.IsNotAcceptable(err): + // ok + default: + t.Errorf("unexpected error on read (subresource %q): %v", subresource, err) + } + } + + createBody, err := cbor.Marshal(map[string]interface{}{ + "apiVersion": "mygroup.example.com/v1beta1", + "kind": "Foo", + "metadata": map[string]interface{}{ + "name": fmt.Sprintf("test-cbor-%s-2", tc.name), + }, + "spec": map[string]interface{}{ + "replicas": int64(0), + }, + "status": map[string]interface{}{ + "replicas": int64(0), + }, + }) + if err != nil { + t.Fatal(err) + } + err = restClient.Post(). + Resource(crd.Spec.Names.Plural). + SetHeader("Content-Type", "application/cbor"). + Body(createBody). + Do(context.TODO()).Error() + switch { + case tc.enabled && err == nil: + // ok + case !tc.enabled && errors.IsUnsupportedMediaType(err): + // ok + default: + t.Errorf("unexpected error on write: %v", err) + } + + scaleBody, err := cbor.Marshal(map[string]interface{}{ + "apiVersion": "autoscaling/v1", + "kind": "Scale", + "metadata": map[string]interface{}{ + "name": cr.GetName(), + }, + "spec": map[string]interface{}{ + "replicas": int64(0), + }, + "status": map[string]interface{}{ + "replicas": int64(0), + }, + }) + if err != nil { + t.Fatal(err) + } + err = restClient.Put(). + Resource(crd.Spec.Names.Plural). + SubResource("scale"). + Name(cr.GetName()). + SetHeader("Content-Type", "application/cbor"). + Body(scaleBody). + Do(context.TODO()).Error() + switch { + case tc.enabled && err == nil: + // ok + case !tc.enabled && errors.IsUnsupportedMediaType(err): + // ok + default: + t.Errorf("unexpected error on scale write: %v", err) + } + + err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { + latest, err := dynamicClient.Resource(schema.GroupVersionResource{Group: "mygroup.example.com", Version: "v1beta1", Resource: "foos"}).Get(context.TODO(), cr.GetName(), metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + statusBody, err := cbor.Marshal(latest.Object) + if err != nil { + t.Fatal(err) + } + + return restClient.Put(). + Resource(crd.Spec.Names.Plural). + SubResource("status"). + Name(cr.GetName()). + SetHeader("Content-Type", "application/cbor"). + Body(statusBody). + Do(context.TODO()).Error() + }) + switch { + case tc.enabled && err == nil: + // ok + case !tc.enabled && errors.IsUnsupportedMediaType(err): + // ok + default: + t.Fatalf("unexpected error on status write: %v", err) + } + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/features/kube_features.go b/staging/src/k8s.io/apiserver/pkg/features/kube_features.go index f4c6fb3a756..1c5d1cc6d49 100644 --- a/staging/src/k8s.io/apiserver/pkg/features/kube_features.go +++ b/staging/src/k8s.io/apiserver/pkg/features/kube_features.go @@ -87,6 +87,15 @@ const ( // Allows authorization to use field and label selectors. AuthorizeWithSelectors featuregate.Feature = "AuthorizeWithSelectors" + // owner: @benluddy + // kep: https://kep.k8s.io/4222 + // + // Enables CBOR as a supported encoding for requests and responses, and as the + // preferred storage encoding for custom resources. + // + // This feature is currently PRE-ALPHA and MUST NOT be enabled outside of integration tests. + TestOnlyCBORServingAndStorage featuregate.Feature = "TestOnlyCBORServingAndStorage" + // owner: @serathius // Enables concurrent watch object decoding to avoid starving watch cache when conversion webhook is installed. ConcurrentWatchObjectDecode featuregate.Feature = "ConcurrentWatchObjectDecode" @@ -238,6 +247,7 @@ const ( func init() { runtime.Must(utilfeature.DefaultMutableFeatureGate.Add(defaultKubernetesFeatureGates)) runtime.Must(utilfeature.DefaultMutableFeatureGate.AddVersioned(defaultVersionedKubernetesFeatureGates)) + runtime.Must(utilfeature.TestOnlyMutableFeatureGate.AddVersioned(testOnlyVersionedKubernetesFeatureGates)) } // defaultVersionedKubernetesFeatureGates consists of all known Kubernetes-specific feature keys with VersionedSpecs. @@ -410,3 +420,12 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate // defaultKubernetesFeatureGates consists of legacy unversioned Kubernetes-specific feature keys. // Please do not add to this struct and use defaultVersionedKubernetesFeatureGates instead. var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{} + +// testOnlyVersionedKubernetesFeatureGates consists of features that require programmatic enablement +// for integration testing, but have not yet graduated to alpha in a release and must not be enabled +// by a runtime option. +var testOnlyVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate.VersionedSpecs{ + TestOnlyCBORServingAndStorage: { + {Version: version.MustParse("1.32"), Default: false, PreRelease: featuregate.Alpha}, + }, +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/config.go b/staging/src/k8s.io/apiserver/pkg/server/config.go index e906f3a1ae5..7130bc3a04a 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/config.go +++ b/staging/src/k8s.io/apiserver/pkg/server/config.go @@ -742,7 +742,7 @@ func (c *RecommendedConfig) Complete() CompletedConfig { return c.Config.Complete(c.SharedInformerFactory) } -var allowedMediaTypes = []string{ +var defaultAllowedMediaTypes = []string{ runtime.ContentTypeJSON, runtime.ContentTypeYAML, runtime.ContentTypeProtobuf, @@ -755,6 +755,10 @@ func (c completedConfig) New(name string, delegationTarget DelegationTarget) (*G if c.Serializer == nil { return nil, fmt.Errorf("Genericapiserver.New() called with config.Serializer == nil") } + allowedMediaTypes := defaultAllowedMediaTypes + if utilfeature.TestOnlyFeatureGate.Enabled(genericfeatures.TestOnlyCBORServingAndStorage) { + allowedMediaTypes = append(allowedMediaTypes, runtime.ContentTypeCBOR) + } for _, info := range c.Serializer.SupportedMediaTypes() { var ok bool for _, mt := range allowedMediaTypes { diff --git a/staging/src/k8s.io/apiserver/pkg/server/config_test.go b/staging/src/k8s.io/apiserver/pkg/server/config_test.go index 75314e2cafa..a1e4d6f2dfd 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/config_test.go +++ b/staging/src/k8s.io/apiserver/pkg/server/config_test.go @@ -29,6 +29,7 @@ import ( "time" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/json" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/wait" @@ -40,12 +41,14 @@ import ( "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/features" "k8s.io/apiserver/pkg/server/healthz" utilfeature "k8s.io/apiserver/pkg/util/feature" utilversion "k8s.io/apiserver/pkg/util/version" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/rest" + featuregatetesting "k8s.io/component-base/featuregate/testing" "k8s.io/component-base/tracing" "k8s.io/klog/v2/ktesting" netutils "k8s.io/utils/net" @@ -419,3 +422,22 @@ func TestNewErrorForbiddenSerializer(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +func TestNewFeatureGatedSerializer(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.TestOnlyFeatureGate, features.TestOnlyCBORServingAndStorage, true) + + config := NewConfig(serializer.NewCodecFactory(scheme, serializer.WithSerializer(func(creater runtime.ObjectCreater, typer runtime.ObjectTyper) runtime.SerializerInfo { + return runtime.SerializerInfo{ + MediaType: "application/cbor", + MediaTypeType: "application", + MediaTypeSubType: "cbor", + } + }))) + config.ExternalAddress = "192.168.10.4:443" + config.EffectiveVersion = utilversion.NewEffectiveVersion("") + config.LoopbackClientConfig = &rest.Config{} + + if _, err := config.Complete(nil).New("test", NewEmptyDelegate()); err != nil { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/util/feature/feature_gate.go b/staging/src/k8s.io/apiserver/pkg/util/feature/feature_gate.go index 00a9e099ba7..7c061042aab 100644 --- a/staging/src/k8s.io/apiserver/pkg/util/feature/feature_gate.go +++ b/staging/src/k8s.io/apiserver/pkg/util/feature/feature_gate.go @@ -31,3 +31,15 @@ var ( // Top-level commands/options setup that needs to modify this feature gate should use DefaultMutableFeatureGate. DefaultFeatureGate featuregate.FeatureGate = DefaultMutableFeatureGate ) + +var ( + // TestOnlyMutableFeatureGate is a mutable version of TestOnlyFeatureGate. Only top-level + // commands/options setup and the k8s.io/component-base/featuregate/testing package should + // make use of this. + TestOnlyMutableFeatureGate featuregate.MutableVersionedFeatureGate = featuregate.NewFeatureGate() + + // TestOnlyFeatureGate is a shared global FeatureGate for features that have not yet + // graduated to alpha and require programmatic feature enablement for pre-alpha integration + // testing without exposing the feature as a runtime option. + TestOnlyFeatureGate featuregate.FeatureGate = TestOnlyMutableFeatureGate +) diff --git a/test/featuregates_linter/test_data/versioned_feature_list.yaml b/test/featuregates_linter/test_data/versioned_feature_list.yaml index 842389216be..1f521fc2c54 100644 --- a/test/featuregates_linter/test_data/versioned_feature_list.yaml +++ b/test/featuregates_linter/test_data/versioned_feature_list.yaml @@ -1228,6 +1228,12 @@ lockToDefault: false preRelease: Beta version: "1.32" +- name: TestOnlyCBORServingAndStorage + versionedSpecs: + - default: false + lockToDefault: false + preRelease: Alpha + version: "1.32" - name: TopologyAwareHints versionedSpecs: - default: false diff --git a/test/integration/framework/cbor.go b/test/integration/framework/cbor.go new file mode 100644 index 00000000000..03025531bf1 --- /dev/null +++ b/test/integration/framework/cbor.go @@ -0,0 +1,70 @@ +/* +Copyright 2024 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 framework + +import ( + "testing" + + apiextensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver" + metainternalscheme "k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/runtime/serializer/cbor" + "k8s.io/apiserver/pkg/features" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" + aggregatorscheme "k8s.io/kube-aggregator/pkg/apiserver/scheme" + "k8s.io/kubernetes/pkg/api/legacyscheme" +) + +// EnableCBORForTest patches global state to enable the CBOR serializer and reverses those changes +// at the end of the test. As a risk mitigation, integration tests are initially written this way so +// that integration tests can be implemented fully and incrementally before exposing options +// (including feature gates) that can enable CBOR at runtime. After integration test coverage is +// complete, feature gates will be introduced to completely supersede this mechanism. +func EnableCBORServingAndStorageForTest(tb testing.TB) { + featuregatetesting.SetFeatureGateDuringTest(tb, utilfeature.TestOnlyFeatureGate, features.TestOnlyCBORServingAndStorage, true) + + newCBORSerializerInfo := func(creater runtime.ObjectCreater, typer runtime.ObjectTyper) runtime.SerializerInfo { + return runtime.SerializerInfo{ + MediaType: "application/cbor", + MediaTypeType: "application", + MediaTypeSubType: "cbor", + Serializer: cbor.NewSerializer(creater, typer), + StrictSerializer: cbor.NewSerializer(creater, typer, cbor.Strict(true)), + StreamSerializer: &runtime.StreamSerializerInfo{ + Framer: cbor.NewFramer(), + Serializer: cbor.NewSerializer(creater, typer, cbor.Transcode(false)), + }, + } + } + + // Codecs for built-in types are constructed at package initialization time and read by + // value from REST storage providers. + codecs := map[*runtime.Scheme]*serializer.CodecFactory{ + legacyscheme.Scheme: &legacyscheme.Codecs, + metainternalscheme.Scheme: &metainternalscheme.Codecs, + aggregatorscheme.Scheme: &aggregatorscheme.Codecs, + apiextensionsapiserver.Scheme: &apiextensionsapiserver.Codecs, + } + + for scheme, factory := range codecs { + original := *factory // shallow copy of original value + tb.Cleanup(func() { *codecs[scheme] = original }) + *codecs[scheme] = serializer.NewCodecFactory(scheme, serializer.WithSerializer(newCBORSerializerInfo)) + } +}