Repository: kyverno/kyverno (2026-03-13T00:10:56Z)

Issue:
[Bug] [CLI] GeneratingPolicy test fails with custom CRDs (GetRESTMapper ignores CRD-aware discovery for fake clients)
### Kyverno CLI Version

1.17.1

### Description

When running Kyverno test for a GeneratingPolicy that matches custom CRD resources, the CLI fails with:

```
failed to map gvk to gvr storage.cnrm.cloud.google.com/v1beta1, Kind=StorageBucket
(no matches for kind "StorageBucket" in version "storage.cnrm.cloud.google.com/v1beta1")
```
Even when CRDs are provided via ClusterResource.spec.crds (introduced in v1.17.1 via #15194), the error persists.

#### Root cause
[pkg/utils/restmapper/restmapper.go#GetRESTMapper](https://github.com/kyverno/kyverno/blob/main/pkg/utils/restmapper/restmapper.go) detects `*kubefake.Clientset` and unconditionally falls back to a static mapper built from `data.APIGroupResources()` (built-in k8s types only). It never consults the CRD-aware `FakeDiscoveryClient` that [ext/cluster/fake.go#DClient](https://github.com/kyverno/kyverno/blob/main/ext/cluster/fake.go) sets up with `AddGVRToGVKMapping`.

```
// restmapper.go — the fake-client branch ignores CRDs entirely
if client != nil && !isFake {
    dc := client.GetKubeClient().Discovery()
    cachedDiscovery := memory.NewMemCacheClient(dc)
    restMapper = restmapper.NewDeferredDiscoveryRESTMapper(cachedDiscovery)
} else {
    // ← always lands here for kyverno test
    apiGroupResources, err := data.APIGroupResources()
    restMapper = restmapper.NewDiscoveryRESTMapper(apiGroupResources)
}
```

The `GeneratingPolicy` code path in [cmd/cli/kubectl-kyverno/commands/test/test.go](https://github.com/kyverno/kyverno/blob/main/cmd/cli/kubectl-kyverno/commands/test/test.go) calls `restMapper.RESTMapping(gvk)` for each input resource, which fails for any custom CRD.

ValidatingPolicy tests with custom CRDs work because they don't go through GetRESTMapper.

#### Possible fix

`GetRESTMapper` could use the `RESTMapper` built by `fakeCluster.RESTMapper(crds)` (which already includes CRDs) instead of creating a fresh static mapper. Alternatively, the `else` branch could query the dclient's discovery client rather than using static data.

### Steps to reproduce

1. Create a `GeneratingPolicy` that matches a custom CRD (e.g. `StorageBucket`):
```
apiVersion: policies.kyverno.io/v1beta1
kind: GeneratingPolicy
metadata:
  name: test-gpol
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: ['storage.cnrm.cloud.google.com']
        apiVersions: ['v1beta1']
        operations: ['CREATE']
        resources: ['storagebuckets']
  generate:
    - expression: |
        generator.Apply("default", [{
          "kind": dyn("ConfigMap"),
          "apiVersion": dyn("v1"),
          "metadata": dyn({"name": dyn("test"), "namespace": dyn("default")})
        }])
```
2. Create a CRD file (storagebucket-crd.yaml):
```
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: storagebuckets.storage.cnrm.cloud.google.com
spec:
  group: storage.cnrm.cloud.google.com
  names:
    kind: StorageBucket
    plural: storagebuckets
    singular: storagebucket
  scope: Namespaced
  versions:
    - name: v1beta1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              x-kubernetes-preserve-unknown-fields: true
```
3. Create a ClusterResource file (clusterresources.yaml):
```
apiVersion: cli.kyverno.io/v1alpha1
kind: ClusterResource
metadata:
  name: clusterresources
spec:
  crds:
    - storagebucket-crd.yaml
```
4. Create a test resource (resource.yaml):
```
apiVersion: storage.cnrm.cloud.google.com/v1beta1
kind: StorageBucket
metadata:
  name: test-bucket
  namespace: default
spec:
  location: us-west1
```
5. Create kyverno-test.yaml:
```
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
  name: test-gpol
clusterResources:
  - clusterresources.yaml
policies:
  - policy.yaml
resources:
  - resource.yaml
results:
  - isGeneratingPolicy: true
    kind: StorageBucket
    policy: test-gpol
    resources: ["default/test-bucket"]
    result: pass
```
6. Run: `kyverno test .`

### Expected behavior

The CLI should use the CRDs from `ClusterResource.spec.crds` to build the GVK→GVR mapping, allowing `GeneratingPolicy` tests with custom CRDs to work offline, the same way `ValidatingPolicy` tests with custom CRDs already work.

### Screenshots

_No response_

### Kyverno logs

```Shell
Loading test  ( kyverno-test.yaml ) ...
  Loading values/variables ...
  Loading policies ...
  Loading resources ...
Loading Kubernetes resources ...
  Loading exceptions ...
  Applying 1 policy to 1 resources with 0 exceptions ...
Error: failed to run test (failed to apply policies on resource test-bucket
(failed to map gvk to gvr storage.cnrm.cloud.google.com/v1beta1, Kind=StorageBucket
(no matches for kind "StorageBucket" in version "storage.cnrm.cloud.google.com/v1beta1")))
```

### Slack discussion

_No response_

### Troubleshooting

- [x] I have read and followed the [troubleshooting guide](https://kyverno.io/docs/troubleshooting/).
- [x] I have searched other issues in this repository and mine is not recorded.


Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template!

Related issue: #7853



Patch:
diff --git a/cmd/cli/kubectl-kyverno/processor/policy_processor.go b/cmd/cli/kubectl-kyverno/processor/policy_processor.go
index c4d9497e82e4..b396acaac40c 100644
--- a/cmd/cli/kubectl-kyverno/processor/policy_processor.go
+++ b/cmd/cli/kubectl-kyverno/processor/policy_processor.go
@@ -105,6 +105,7 @@ type PolicyProcessor struct {
 	CrdPath                   string
 	NamespaceCache            map[string]*unstructured.Unstructured
 	ConfigMapResolver         engineapi.ConfigmapResolver
+	RESTMapper                meta.RESTMapper
 }
 
 func (p *PolicyProcessor) ApplyPoliciesOnResource() ([]engineapi.EngineResponse, error) {
@@ -256,9 +257,13 @@ func (p *PolicyProcessor) ApplyPoliciesOnResource() ([]engineapi.EngineResponse,
 		resource = validateResponse.PatchedResource
 	}
 
-	restMapper, err := utils.GetRESTMapper(p.Client)
-	if err != nil {
-		return nil, err
+	restMapper := p.RESTMapper
+	if restMapper == nil {
+		var err error
+		restMapper, err = utils.GetRESTMapper(p.Client)
+		if err != nil {
+			return nil, err
+		}
 	}
 	// Mutate Admission Policies
 	if len(p.MutatingAdmissionPolicies) != 0 {


Test patch:
diff --git a/cmd/cli/kubectl-kyverno/commands/test/test.go b/cmd/cli/kubectl-kyverno/commands/test/test.go
index 5f64f656f92d..dd60a8fbd076 100644
--- a/cmd/cli/kubectl-kyverno/commands/test/test.go
+++ b/cmd/cli/kubectl-kyverno/commands/test/test.go
@@ -48,6 +48,7 @@ import (
 	authenticationv1 "k8s.io/api/authentication/v1"
 	corev1 "k8s.io/api/core/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
@@ -197,9 +198,11 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 	}
 
 	var cmResolver engineapi.ConfigmapResolver
+	var restMapper meta.RESTMapper
 	if len(testCase.Test.ClusterResources) > 0 {
 		fmt.Fprintln(out, "Loading Kubernetes resources", "...")
 
+		allCRDs := []*apiextensionsv1.CustomResourceDefinition{}
 		for _, p := range path.GetFullPaths(testCase.Test.ClusterResources, testDir, isGit) {
 			src, err := common.LoadYAML(testCase.Fs, p, func() *v1alpha1.ClusterResource {
 				return &v1alpha1.ClusterResource{}
@@ -207,7 +210,6 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 			if err != nil {
 				return nil, fmt.Errorf("error: failed to load Kubernetes resources: %s", err)
 			}
-			crds := []*apiextensionsv1.CustomResourceDefinition{}
 			if len(src.Spec.CRDs) > 0 {
 				for _, crdFullPath := range path.GetFullPaths(src.Spec.CRDs, testDir, isGit) {
 					crd, err := common.LoadYAML(testCase.Fs, crdFullPath, func() *apiextensionsv1.CustomResourceDefinition {
@@ -216,7 +218,7 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 					if err != nil {
 						return nil, fmt.Errorf("error: failed to load CRDs from path %s: %s", crdFullPath, err)
 					}
-					crds = append(crds, crd)
+					allCRDs = append(allCRDs, crd)
 				}
 			}
 			if len(src.Spec.Resources) > 0 {
@@ -224,10 +226,10 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 					allObjects = append(allObjects, resource)
 				}
 			}
-			cl.RESTMapper(crds)
-			for _, crd := range crds {
-				allObjects = append(allObjects, crd)
-			}
+		}
+		restMapper = cl.RESTMapper(allCRDs)
+		for _, crd := range allCRDs {
+			allObjects = append(allObjects, crd)
 		}
 
 		dClient, err = cl.DClient(allObjects)
@@ -370,6 +372,7 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 			Subresources:                      vars.Subresources(),
 			Out:                               io.Discard,
 			ConfigMapResolver:                 cmResolver,
+			RESTMapper:                        restMapper,
 		}
 		ers, err := processor.ApplyPoliciesOnResource()
 		if err != nil {
@@ -390,6 +393,7 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 				contextPath,
 				false,
 				!(len(testCase.Test.ClusterResources) > 0),
+				restMapper,
 			)
 			if err != nil {
 				return nil, fmt.Errorf("failed to apply policies on resource %v (%w)", resource.GetName(), err)
@@ -409,6 +413,7 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 				testCase.Fs,
 				contextPath,
 				true,
+				restMapper,
 			)
 			if err != nil {
 				return nil, fmt.Errorf("failed to apply policies on resource %v (%w)", resource.GetName(), err)
@@ -450,6 +455,7 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 			Client:                            dClient,
 			Subresources:                      vars.Subresources(),
 			Out:                               io.Discard,
+			RESTMapper:                        restMapper,
 		}
 		ers, err := processor.ApplyPoliciesOnResource()
 		if err != nil {
@@ -470,6 +476,7 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 				contextPath,
 				false,
 				true,
+				restMapper,
 			)
 			if err != nil {
 				return nil, fmt.Errorf("failed to apply validating policies on JSON payload %s (%w)", testCase.Test.JSONPayload, err)
@@ -489,6 +496,7 @@ func runTest(out io.Writer, testCase test.TestCase, registryAccess bool) (*TestR
 				testCase.Fs,
 				contextPath,
 				true,
+				restMapper,
 			)
 			if err != nil {
 				return nil, fmt.Errorf("failed to apply policies on JSON payload %v (%w)", testCase.Test.JSONPayload, err)
@@ -548,6 +556,7 @@ func applyImageValidatingPolicies(
 	contextPath string,
 	continueOnFail bool,
 	isFake bool,
+	restMapper meta.RESTMapper,
 ) ([]engineapi.EngineResponse, error) {
 	provider, err := ivpolengine.NewProvider(ivps, celExceptions)
 	if err != nil {
@@ -564,9 +573,12 @@ func applyImageValidatingPolicies(
 		lister,
 		[]imagedataloader.Option{imagedataloader.WithLocalCredentials(registryAccess)},
 	)
-	restMapper, err := utils.GetRESTMapper(dclient)
-	if err != nil {
-		return nil, err
+	if restMapper == nil {
+		var mapErr error
+		restMapper, mapErr = utils.GetRESTMapper(dclient)
+		if mapErr != nil {
+			return nil, mapErr
+		}
 	}
 	contextProvider, err := processor.NewContextProvider(dclient, restMapper, f, contextPath, registryAccess, isFake)
 	if err != nil {
@@ -680,10 +692,14 @@ func applyDeletingPolicies(
 	f billy.Filesystem,
 	contextPath string,
 	isFake bool,
+	restMapper meta.RESTMapper,
 ) ([]engineapi.EngineResponse, error) {
-	restMapper, err := utils.GetRESTMapper(dclient)
-	if err != nil {
-		return nil, err
+	if restMapper == nil {
+		var mapErr error
+		restMapper, mapErr = utils.GetRESTMapper(dclient)
+		if mapErr != nil {
+			return nil, mapErr
+		}
 	}
 	contextProvider, err := processor.NewContextProvider(dclient, restMapper, f, contextPath, registryAccess, isFake)
 	if err != nil {
diff --git a/test/cli/test-gpol-custom-crd/clusterresources.yaml b/test/cli/test-gpol-custom-crd/clusterresources.yaml
new file mode 100644
index 000000000000..7ca7a76a8791
--- /dev/null
+++ b/test/cli/test-gpol-custom-crd/clusterresources.yaml
@@ -0,0 +1,12 @@
+apiVersion: cli.kyverno.io/v1alpha1
+kind: ClusterResource
+metadata:
+  name: clusterresources
+spec:
+  crds:
+    - crds/widget-crd.yaml
+  resources:
+    - apiVersion: v1
+      kind: Namespace
+      metadata:
+        name: test-namespace
diff --git a/test/cli/test-gpol-custom-crd/crds/widget-crd.yaml b/test/cli/test-gpol-custom-crd/crds/widget-crd.yaml
new file mode 100644
index 000000000000..16b2e4d8e7e2
--- /dev/null
+++ b/test/cli/test-gpol-custom-crd/crds/widget-crd.yaml
@@ -0,0 +1,22 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: widgets.example.com
+spec:
+  group: example.com
+  names:
+    kind: Widget
+    plural: widgets
+    singular: widget
+  scope: Namespaced
+  versions:
+    - name: v1
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              x-kubernetes-preserve-unknown-fields: true
diff --git a/test/cli/test-gpol-custom-crd/generated/configmap.yaml b/test/cli/test-gpol-custom-crd/generated/configmap.yaml
new file mode 100644
index 000000000000..fde864307811
--- /dev/null
+++ b/test/cli/test-gpol-custom-crd/generated/configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: widget-my-widget
+  namespace: test-namespace
+data:
+  widget-name: my-widget
diff --git a/test/cli/test-gpol-custom-crd/kyverno-test.yaml b/test/cli/test-gpol-custom-crd/kyverno-test.yaml
new file mode 100644
index 000000000000..788a3568529c
--- /dev/null
+++ b/test/cli/test-gpol-custom-crd/kyverno-test.yaml
@@ -0,0 +1,18 @@
+apiVersion: cli.kyverno.io/v1alpha1
+clusterResources:
+- clusterresources.yaml
+kind: Test
+metadata:
+  name: test-gpol-custom-crd
+policies:
+- policy.yaml
+resources:
+- resources/widget.yaml
+results:
+- generatedResource: generated/configmap.yaml
+  isGeneratingPolicy: true
+  kind: Widget
+  policy: generate-configmap-for-widget
+  resources:
+  - test-namespace/my-widget
+  result: pass
diff --git a/test/cli/test-gpol-custom-crd/policy.yaml b/test/cli/test-gpol-custom-crd/policy.yaml
new file mode 100644
index 000000000000..ad3895ca8691
--- /dev/null
+++ b/test/cli/test-gpol-custom-crd/policy.yaml
@@ -0,0 +1,27 @@
+apiVersion: policies.kyverno.io/v1beta1
+kind: GeneratingPolicy
+metadata:
+  name: generate-configmap-for-widget
+spec:
+  matchConstraints:
+    resourceRules:
+      - apiGroups: ['example.com']
+        apiVersions: ['v1']
+        operations: ['CREATE']
+        resources: ['widgets']
+  variables:
+    - name: widgetName
+      expression: 'object.metadata.name'
+  generate:
+    - expression: |
+        generator.Apply("test-namespace", [{
+          "kind": dyn("ConfigMap"),
+          "apiVersion": dyn("v1"),
+          "metadata": dyn({
+            "name": dyn("widget-" + variables.widgetName),
+            "namespace": dyn("test-namespace")
+          }),
+          "data": dyn({
+            "widget-name": dyn(variables.widgetName)
+          })
+        }])
diff --git a/test/cli/test-gpol-custom-crd/resources/widget.yaml b/test/cli/test-gpol-custom-crd/resources/widget.yaml
new file mode 100644
index 000000000000..5110b61a3b3d
--- /dev/null
+++ b/test/cli/test-gpol-custom-crd/resources/widget.yaml
@@ -0,0 +1,7 @@
+apiVersion: example.com/v1
+kind: Widget
+metadata:
+  name: my-widget
+  namespace: test-namespace
+spec:
+  color: blue