diff --git a/internal/tester/loader.go b/internal/tester/loader.go index 2a6a14a..8cd5d6a 100644 --- a/internal/tester/loader.go +++ b/internal/tester/loader.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "log/slog" + "math" "os" "github.com/yannh/kubeconform/pkg/resource" @@ -142,6 +143,8 @@ func (r *ResourceLoader) LoadResources(paths []string) { slog.Warn("failed to decode resource", "error", err) continue } + // ensure numbers to be int if possible + normalizeObject(obj) unstructuredObj := &unstructured.Unstructured{Object: obj} // if resource manifest validation is enabled, check whether the resource manifest follows a schema. @@ -193,3 +196,34 @@ func defaultingMAPPolicy(p *v1alpha1.MutatingAdmissionPolicy) { p.Spec.MatchConstraints.ObjectSelector = &metav1.LabelSelector{} } } + +// normalizeObject ensures int-able values to be int. +func normalizeObject(obj map[string]any) { + for k, v := range obj { + obj[k] = normalizeValue(v) + } +} + +func normalizeArray(arr []any) { + for i := range arr { + arr[i] = normalizeValue(arr[i]) + } +} + +func normalizeValue(v any) any { + switch val := v.(type) { + case map[string]any: + normalizeObject(val) + return val + case []any: + normalizeArray(val) + return val + case float64: + if math.Trunc(val) == float64(int64(val)) { + return int64(val) + } + return val + default: + return v + } +} diff --git a/internal/tester/testdata/map-custom-resources.test/invalid-no-obj.yaml b/internal/tester/testdata/map-custom-resources.test/invalid-no-obj.yaml new file mode 100644 index 0000000..c0369fd --- /dev/null +++ b/internal/tester/testdata/map-custom-resources.test/invalid-no-obj.yaml @@ -0,0 +1,13 @@ +policies: +- ../vap-custom-resources.yaml +resources: +- resources.yaml +schemaLocations: +- "https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json" +vapTestSuites: +- policy: httpproxy-auth + tests: + - object: + kind: HTTPProxy + name: not-exist + expect: admit diff --git a/internal/tester/testdata/map-custom-resources.test/kaptest.yaml b/internal/tester/testdata/map-custom-resources.test/kaptest.yaml new file mode 100644 index 0000000..ab58871 --- /dev/null +++ b/internal/tester/testdata/map-custom-resources.test/kaptest.yaml @@ -0,0 +1,16 @@ +policies: +- ../map-custom-resources.yaml +resources: +- resources.yaml +schemaLocations: +- "https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json" +mapTestSuites: +- policy: httpproxy-update + tests: + - object: + kind: HTTPProxy + name: base + expect: mutate + expectObject: + kind: HTTPProxy + name: updated diff --git a/internal/tester/testdata/map-custom-resources.test/resources.yaml b/internal/tester/testdata/map-custom-resources.test/resources.yaml new file mode 100644 index 0000000..ddee5d3 --- /dev/null +++ b/internal/tester/testdata/map-custom-resources.test/resources.yaml @@ -0,0 +1,27 @@ +apiVersion: projectcontour.io/v1 +kind: HTTPProxy +metadata: + name: base +spec: + virtualhost: + fqdn: foo-basic.bar.com + routes: + - conditions: + - prefix: / + services: + - name: s1 + port: 80 +--- +apiVersion: projectcontour.io/v1 +kind: HTTPProxy +metadata: + name: updated +spec: + virtualhost: + fqdn: mutated.foo-basic.bar.com + routes: + - conditions: + - prefix: / + services: + - name: s1 + port: 8080 \ No newline at end of file diff --git a/internal/tester/testdata/map-custom-resources.yaml b/internal/tester/testdata/map-custom-resources.yaml new file mode 100644 index 0000000..935594c --- /dev/null +++ b/internal/tester/testdata/map-custom-resources.yaml @@ -0,0 +1,38 @@ +apiVersion: admissionregistration.k8s.io/v1alpha1 +kind: MutatingAdmissionPolicy +metadata: + name: httpproxy-update +spec: + matchConstraints: + matchPolicy: "Equivalent" + namespaceSelector: {} + objectSelector: {} + resourceRules: + - apiGroups: ["projectcontour.io"] + apiVersions: ["*"] + operations: ["CREATE", "UPDATE"] + resources: ["httpproxies"] + failurePolicy: Fail + reinvocationPolicy: IfNeeded + mutations: + - patchType: ApplyConfiguration + applyConfiguration: + expression: >- + Object{ + spec: Object.spec{ + virtualhost: Object.spec.virtualhost{ + fqdn: "mutated.foo-basic.bar.com", + }, + routes: object.spec.routes.map( + x, Object.spec.routes{ + conditions: x.conditions, + services: x.services.map( + y, Object.spec.routes.services{ + name: y.name, + port: 8080, + } + ), + } + ), + } + } diff --git a/internal/tester/testdata/map-with-crd-params.test/kaptest.yaml b/internal/tester/testdata/map-with-crd-params.test/kaptest.yaml new file mode 100644 index 0000000..e2575bc --- /dev/null +++ b/internal/tester/testdata/map-with-crd-params.test/kaptest.yaml @@ -0,0 +1,25 @@ +policies: +- ../map-with-crd-params.yaml +resources: +- resources.yaml +mapTestSuites: +- policy: deployment-replicas + tests: + - object: + kind: Deployment + name: small + namespace: foo + param: + name: my-config + expect: mutate + expectObject: + kind: Deployment + name: ok + namespace: foo + - object: + kind: Deployment + name: ok + namespace: foo + param: + name: my-config + expect: skip diff --git a/internal/tester/testdata/map-with-crd-params.test/resources.yaml b/internal/tester/testdata/map-with-crd-params.test/resources.yaml new file mode 100644 index 0000000..8db1a67 --- /dev/null +++ b/internal/tester/testdata/map-with-crd-params.test/resources.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ok + namespace: foo + labels: + app: ok-deployment +spec: + replicas: 5 + selector: + matchLabels: + app: ok-deployment + template: + metadata: + labels: + app: ok-deployment + spec: + containers: + - name: nginx + image: nginx +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: small + namespace: foo + labels: + app: ok-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: ok-deployment + template: + metadata: + labels: + app: ok-deployment + spec: + containers: + - name: nginx + image: nginx +--- +apiVersion: example.com/v1 +kind: MyCustomResource +metadata: + name: my-config + namespace: hoge +spec: + maxReplicas: 5 \ No newline at end of file diff --git a/internal/tester/testdata/map-with-crd-params.yaml b/internal/tester/testdata/map-with-crd-params.yaml new file mode 100644 index 0000000..7bd7e41 --- /dev/null +++ b/internal/tester/testdata/map-with-crd-params.yaml @@ -0,0 +1,34 @@ +apiVersion: admissionregistration.k8s.io/v1alpha1 +kind: MutatingAdmissionPolicy +metadata: + name: deployment-replicas +spec: + matchConstraints: + matchPolicy: "Equivalent" + namespaceSelector: {} + objectSelector: {} + resourceRules: + - apiGroups: ["*"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments"] + failurePolicy: Fail + reinvocationPolicy: IfNeeded + paramKind: + apiVersion: example.com/v1 + kind: MyCustomResource + variables: + - name: maxReplicas + expression: int(params.spec.maxReplicas) + matchConditions: + - name: replicas-increase + expression: "object.spec.replicas < int(params.spec.maxReplicas)" + mutations: + - patchType: ApplyConfiguration + applyConfiguration: + expression: >- + Object{ + spec: Object.spec { + replicas: variables.maxReplicas + } + } diff --git a/internal/tester/testdata/vap-with-crd-params.test/kaptest.yaml b/internal/tester/testdata/vap-with-crd-params.test/kaptest.yaml new file mode 100644 index 0000000..ad30b92 --- /dev/null +++ b/internal/tester/testdata/vap-with-crd-params.test/kaptest.yaml @@ -0,0 +1,20 @@ +policies: +- ../vap-with-crd-params.yaml +resources: +- resources.yaml +vapTestSuites: +- policy: deployment-replicas + tests: + - object: + kind: Deployment + name: ok + param: + name: my-config + expect: admit + - object: + kind: Deployment + name: bad + param: + name: my-config + expect: deny + deniedMessage: "replicas must be equal or less than 5" diff --git a/internal/tester/testdata/vap-with-crd-params.test/resources.yaml b/internal/tester/testdata/vap-with-crd-params.test/resources.yaml new file mode 100644 index 0000000..cffd8d3 --- /dev/null +++ b/internal/tester/testdata/vap-with-crd-params.test/resources.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ok + labels: + app: ok-deployment +spec: + replicas: 5 + selector: + matchLabels: + app: ok-deployment + template: + metadata: + labels: + app: ok-deployment + spec: + containers: + - name: nginx + image: nginx +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bad + labels: + app: bad-deployment +spec: + replicas: 6 + selector: + matchLabels: + app: bad-deployment + template: + metadata: + labels: + app: bad-deployment + spec: + containers: + - name: nginx + image: nginx +--- +apiVersion: example.com/v1 +kind: MyCustomResource +metadata: + name: my-config + namespace: hoge +spec: + maxReplicas: 5 \ No newline at end of file diff --git a/internal/tester/testdata/vap-with-crd-params.yaml b/internal/tester/testdata/vap-with-crd-params.yaml new file mode 100644 index 0000000..7de27c6 --- /dev/null +++ b/internal/tester/testdata/vap-with-crd-params.yaml @@ -0,0 +1,24 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: deployment-replicas +spec: + failurePolicy: Fail + matchConstraints: + matchPolicy: "Equivalent" + namespaceSelector: {} + objectSelector: {} + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["deployments"] + paramKind: + apiVersion: example.com/v1 + kind: MyCustomResource + variables: + - expression: "has(object.spec.replicas) ? object.spec.replicas : 1" + name: replicas + validations: + - expression: variables.replicas <= int(params.spec.maxReplicas) + messageExpression: "'replicas must be equal or less than ' + string(params.spec.maxReplicas)" diff --git a/internal/tester/tester.go b/internal/tester/tester.go index 1465a9d..b4e8b59 100644 --- a/internal/tester/tester.go +++ b/internal/tester/tester.go @@ -311,9 +311,9 @@ func newValidationParams(vap *v1.ValidatingAdmissionPolicy, tc VAPTestCase, load } return kaptest.ValidationParams{ - Object: obj, - OldObject: oldObj, - ParamObj: paramObj, + Object: ensureObject(obj), + OldObject: ensureObject(oldObj), + ParamObj: ensureObject(paramObj), NamespaceObj: namespaceObj, UserInfo: &userInfo, }, nil @@ -368,34 +368,43 @@ func newMutationParams(mp *v1alpha1.MutatingAdmissionPolicy, tc MAPTestCase, loa userInfo := NewK8sUserInfo(tc.UserInfo) - // We need to ensure the object follows scheme - // by converting unstructured object into typed object - // TODO: support CRD - objs := []*unstructured.Unstructured{obj, oldObj, paramObj, expectObj} - typedObjs := []runtime.Object{nil, nil, nil, nil} - for idx, o := range objs { - if o == nil { - continue - } - typedObjs[idx], err = convertToTyped(o) - if err != nil { - errs = append(errs, fmt.Errorf("failed to convert %s to typed object: %w", o.GetObjectKind().GroupVersionKind(), err)) - } - } - if len(errs) > 0 { return kaptest.MutationParams{}, nil, errs } + var runtimeParamObj runtime.Object + if paramObj != nil { + // this conversion is necessary for configmap to avoid following error + // pkg/mod/k8s.io/client-go@v0.32.1/tools/cache/reflector.go:251: + // failed to list /v1, Resource=configmaps: item[0]: can't assign or convert unstructured.Unstructured into v1.ConfigMap + // "Unhandled Error" err="pkg/mod/k8s.io/client-go@v0.32.1/tools/cache/reflector.go:251: + // Failed to watch /v1, Resource=configmaps: failed to list /v1, Resource=configmaps: item[0]: + // can't assign or convert unstructured.Unstructured into v1.ConfigMap" logger="UnhandledError" + // TODO: why this error happens? + runtimeParamObj, err = convertToTyped(paramObj) + if err != nil { + return kaptest.MutationParams{}, nil, []error{fmt.Errorf("convert param to typed object: %w", err)} + } + } + param := kaptest.MutationParams{ - Object: typedObjs[0], - OldObject: typedObjs[1], - ParamObj: typedObjs[2], + Object: ensureObject(obj), + OldObject: ensureObject(oldObj), + ParamObj: runtimeParamObj, NamespaceObj: namespaceObj, UserInfo: &userInfo, } - return param, typedObjs[3], nil + return param, expectObj, nil +} + +// ensureObject ensures runtime.Object not to be nil. +func ensureObject(obj *unstructured.Unstructured) runtime.Object { + if obj == nil { + var nilObj runtime.Object + return nilObj + } + return obj } func getParamObj(loader *ResourceLoader, paramGVK schema.GroupVersionKind, param NamespacedName) (*unstructured.Unstructured, error) { @@ -474,7 +483,8 @@ func convertToTyped(obj *unstructured.Unstructured) (runtime.Object, error) { gvk := obj.GroupVersionKind() newTypedObject, err := scheme.New(gvk) if err != nil { - return nil, fmt.Errorf("GVK %s is not registered in the scheme: %w", gvk, err) + slog.Debug("GVK is not registered in the scheme, fallback to unstructured object", "gvk", gvk.String(), "error", err) + return obj.DeepCopy(), nil } err = runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, newTypedObject) diff --git a/internal/tester/tester_test.go b/internal/tester/tester_test.go index 72d1c55..feac3cf 100644 --- a/internal/tester/tester_test.go +++ b/internal/tester/tester_test.go @@ -39,6 +39,7 @@ func TestRun(t *testing.T) { "./testdata/vap-with-namespaces.test/kaptest.yaml", "./testdata/vap-with-userinfo.test/kaptest.yaml", "./testdata/map-standard-resources.test/kaptest.yaml", + "./testdata/map-custom-resources.test/kaptest.yaml", "./testdata/map-with-params.test/kaptest.yaml", "./testdata/map-with-namespaces.test/kaptest.yaml", "./testdata/map-with-userinfo.test/kaptest.yaml", @@ -86,8 +87,11 @@ func TestRun(t *testing.T) { validateManifests: true, }, { - name: "err: object not exist (custom resource)", - args: []string{"./testdata/vap-custom-resources.test/invalid-no-obj.yaml"}, + name: "err: object not exist (custom resource)", + args: []string{ + "./testdata/vap-custom-resources.test/invalid-no-obj.yaml", + "./testdata/map-custom-resources.test/invalid-no-obj.yaml", + }, wantErr: ErrTestFail, validateManifests: true, }, @@ -130,6 +134,8 @@ func TestRun(t *testing.T) { args: []string{ "./testdata/vap-standard-resources.test/invalid-resources-test.yaml", "./testdata/vap-custom-resources.test/no-schema-locations.yaml", + "./testdata/vap-with-crd-params.test/kaptest.yaml", + "./testdata/map-with-crd-params.test/kaptest.yaml", }, wantErr: nil, validateManifests: false, diff --git a/mutation.go b/mutation.go index 199553b..338255e 100644 --- a/mutation.go +++ b/mutation.go @@ -28,6 +28,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/managedfields" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apiserver/pkg/admission" plugincel "k8s.io/apiserver/pkg/admission/plugin/cel" @@ -40,6 +41,8 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" apiservercel "k8s.io/apiserver/pkg/cel" "k8s.io/apiserver/pkg/cel/environment" + "k8s.io/client-go/dynamic/dynamicinformer" + dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -108,18 +111,21 @@ func NewMutator(policy *v1alpha1.MutatingAdmissionPolicy) (*Mutator, error) { } type mutatorContext struct { - tcm patch.TypeConverterManager - auth authorizer.Authorizer - objInterface admission.ObjectInterfaces - matcher *matching.Matcher - client *fake.Clientset - informerFactory informers.SharedInformerFactory + tcm patch.TypeConverterManager + auth authorizer.Authorizer + objInterface admission.ObjectInterfaces + matcher *matching.Matcher + namespaceClient *fake.Clientset + namespaceInformerFactory informers.SharedInformerFactory + dynamicClient *dynamicfake.FakeDynamicClient + dynamicInformerFactory dynamicinformer.DynamicSharedInformerFactory } -func newMutatorContext(ctx context.Context) (*mutatorContext, error) { - // Prepare TypeConvertManager - // TODO: support CRDs - tcm := patch.NewTypeConverterManager(nil, openapitest.NewEmbeddedFileClient()) +func newMutatorContext(ctx context.Context, policy *v1alpha1.MutatingAdmissionPolicy) (*mutatorContext, error) { + // DeducedConverter for CRDs without schemas still works. + // TODO: allow supplying CRD schemas for better merge semantics. + staticConverter := managedfields.NewDeducedTypeConverter() + tcm := patch.NewTypeConverterManager(staticConverter, openapitest.NewEmbeddedFileClient()) go tcm.Run(ctx) err := wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, time.Second, false, func(context.Context) (done bool, err error) { @@ -146,34 +152,64 @@ func newMutatorContext(ctx context.Context) (*mutatorContext, error) { // What will happen when mutating with the default values? objInterface := admission.NewObjectInterfacesFromScheme(scheme) - // Prepare Client - client := fake.NewClientset() - + // Prepare native fake client for namespaces + namespaceClient := fake.NewClientset() + namespaceInformerFactory := informers.NewSharedInformerFactory(namespaceClient, 0) // Prepare matcher - informerFactory := informers.NewSharedInformerFactory(client, 0) - matcher := matching.NewMatcher(informerFactory.Core().V1().Namespaces().Lister(), client) + matcher := matching.NewMatcher(namespaceInformerFactory.Core().V1().Namespaces().Lister(), namespaceClient) + + gvrToListKind := map[schema.GroupVersionResource]string{} + if policy != nil && policy.Spec.ParamKind != nil { + gv, err := schema.ParseGroupVersion(policy.Spec.ParamKind.APIVersion) + if err != nil { + return nil, fmt.Errorf("failed to parse paramKind APIVersion: %w", err) + } + gvk := schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: policy.Spec.ParamKind.Kind, + } + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + gvrToListKind[gvr] = gvk.Kind + "List" + } + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToListKind) + dynamicInformerFactory := dynamicinformer.NewDynamicSharedInformerFactory(dynamicClient, 0) return &mutatorContext{ - tcm: tcm, - auth: authorizer, - objInterface: objInterface, - matcher: matcher, - client: client, - informerFactory: informerFactory, + tcm: tcm, + auth: authorizer, + objInterface: objInterface, + matcher: matcher, + namespaceClient: namespaceClient, + namespaceInformerFactory: namespaceInformerFactory, + dynamicClient: dynamicClient, + dynamicInformerFactory: dynamicInformerFactory, }, nil } func (mc *mutatorContext) addObjectAndEnsureSynced(ctx context.Context, obj runtime.Object) error { - err := mc.client.Tracker().Add(obj) - if err != nil { - return fmt.Errorf("failed to add object: %w", err) - } - // TODO: better GVR handling - gvr, _ := meta.UnsafeGuessKindToResource(obj.GetObjectKind().GroupVersionKind()) - informer, err := mc.informerFactory.ForResource(gvr) - if err != nil { - return fmt.Errorf("failed to get informer: %w", err) + var informer informers.GenericInformer + if obj.GetObjectKind().GroupVersionKind().String() == "/v1, Kind=Namespace" { + // Namespace object needs to be handled built-in fake client + err := mc.namespaceClient.Tracker().Add(obj) + if err != nil { + return fmt.Errorf("failed to add object: %w", err) + } + informer, err = mc.namespaceInformerFactory.ForResource(schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}) + if err != nil { + return fmt.Errorf("failed to get informer: %w", err) + } + } else { + err := mc.dynamicClient.Tracker().Add(obj) + if err != nil { + return fmt.Errorf("failed to add object: %w", err) + } + // TODO: better GVR handling + gvr, _ := meta.UnsafeGuessKindToResource(obj.GetObjectKind().GroupVersionKind()) + informer = mc.dynamicInformerFactory.ForResource(gvr) } + + // ensure informer cache is synced acc, err := meta.Accessor(obj) if err != nil { return fmt.Errorf("failed to get accessor: %w", err) @@ -275,7 +311,7 @@ func (m *Mutator) dispatchImpl(p MutationParams, dispatcherFactory func(mCtx *mu ctx, cancel := context.WithCancel(context.Background()) defer cancel() - mCtx, err := newMutatorContext(ctx) + mCtx, err := newMutatorContext(ctx, m.policy) if err != nil { return nil, fmt.Errorf("failed to initialize mutatorContext: %w", err) } @@ -308,10 +344,7 @@ func (m *Mutator) dispatchImpl(p MutationParams, dispatcherFactory func(mCtx *mu Version: paramGV.Version, Kind: m.policy.Spec.ParamKind.Kind, }) - paramInformer, err := mCtx.informerFactory.ForResource(paramGVR) - if err != nil { - return nil, fmt.Errorf("failed to create informer for params: %w", err) - } + paramInformer := mCtx.dynamicInformerFactory.ForResource(paramGVR) hook.ParamInformer = paramInformer // TODO: Configure this correctly @@ -327,8 +360,10 @@ func (m *Mutator) dispatchImpl(p MutationParams, dispatcherFactory func(mCtx *mu } // Start informers - mCtx.informerFactory.WaitForCacheSync(ctx.Done()) - mCtx.informerFactory.Start(ctx.Done()) + mCtx.namespaceInformerFactory.WaitForCacheSync(ctx.Done()) + mCtx.namespaceInformerFactory.Start(ctx.Done()) + mCtx.dynamicInformerFactory.WaitForCacheSync(ctx.Done()) + mCtx.dynamicInformerFactory.Start(ctx.Done()) objs := []runtime.Object{} if p.ParamObj != nil { diff --git a/mutation_test.go b/mutation_test.go index 4650e59..91b0d15 100644 --- a/mutation_test.go +++ b/mutation_test.go @@ -26,6 +26,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/utils/ptr" ) @@ -285,7 +286,15 @@ func TestMutator_Mutate_SimplePolicy_WithParam(t *testing.T) { if len(matchedHooks) != 1 { t.Errorf("expected %d matches, but %d matches", 1, len(matchedHooks)) } - if !equality.Semantic.DeepEqual(matchedHooks[0].Invocation.Param, p.ParamObj) { + actualParamObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(matchedHooks[0].Invocation.Param) + if err != nil { + t.Fatal(err) + } + expectedParamObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(p.ParamObj) + if err != nil { + t.Fatal(err) + } + if !equality.Semantic.DeepEqual(actualParamObj, expectedParamObj) { t.Errorf("unexpected param is matched") }