From c713bf1815ffeb9bb20bf1e3e1225779a5c169ee Mon Sep 17 00:00:00 2001
From: Brett Tofel <btofel@redhat.com>
Date: Wed, 30 Jul 2025 16:09:59 -0400
Subject: [PATCH 1/5] Initial work to configuration PoC

Signed-off-by: Brett Tofel <btofel@redhat.com>
---
 api/v1/clusterextension_types.go              |  5 ++
 cmd/demo-config/main.go                       | 56 +++++++++++++++++++
 .../testdata/api/v1/clusterextension_types.go |  5 ++
 .../registryv1/generators/generators.go       | 25 +++++++++
 .../rukpak/render/render.go                   |  9 +++
 test/convert/generate-manifests.go            | 16 +++++-
 .../bundles/test-operator/v2.0.0/config.yaml  |  2 +
 7 files changed, 116 insertions(+), 2 deletions(-)
 create mode 100644 cmd/demo-config/main.go
 create mode 100644 testdata/images/bundles/test-operator/v2.0.0/config.yaml

diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go
index 0141f1a7a..3f43f7ccd 100644
--- a/api/v1/clusterextension_types.go
+++ b/api/v1/clusterextension_types.go
@@ -92,6 +92,11 @@ type ClusterExtensionSpec struct {
 	//
 	// +optional
 	Install *ClusterExtensionInstallConfig `json:"install,omitempty"`
+
+	// config contains arbitrary configuration values to be applied at render time.
+	// These values will be merged into the bundle manifests during rendering.
+	// +optional
+	Config map[string]interface{} `json:"config,omitempty"`
 }
 
 const SourceTypeCatalog = "Catalog"
diff --git a/cmd/demo-config/main.go b/cmd/demo-config/main.go
new file mode 100644
index 000000000..9578931c0
--- /dev/null
+++ b/cmd/demo-config/main.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"sigs.k8s.io/yaml"
+
+	"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source"
+	"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render"
+	"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/registryv1"
+)
+
+func main() {
+	if len(os.Args) != 3 {
+		fmt.Fprintf(os.Stderr, "usage: %s <bundle-dir> <install-namespace>\n", os.Args[0])
+		os.Exit(1)
+	}
+	bundleDir := os.Args[1]
+	installNs := os.Args[2]
+
+	// load optional configuration values from config.yaml
+	cfg := map[string]interface{}{}
+	configFile := filepath.Join(bundleDir, "config.yaml")
+	if data, err := os.ReadFile(configFile); err == nil {
+		if err := yaml.Unmarshal(data, &cfg); err != nil {
+			fmt.Fprintf(os.Stderr, "error unmarshalling config file %q: %v\n", configFile, err)
+			os.Exit(1)
+		}
+	}
+
+	// parse registry+v1 bundle
+	regv1, err := source.FromFS(os.DirFS(bundleDir)).GetBundle()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "error parsing bundle directory: %v\n", err)
+		os.Exit(1)
+	}
+
+	// render bundle with configuration
+	opts := []render.Option{render.WithConfig(cfg)}
+	objs, err := registryv1.Renderer.Render(regv1, installNs, opts...)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "error rendering bundle: %v\n", err)
+		os.Exit(1)
+	}
+
+	for _, obj := range objs {
+		data, err := yaml.Marshal(obj)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "error marshaling object: %v\n", err)
+			os.Exit(1)
+		}
+		fmt.Printf("---\n%s", string(data))
+	}
+}
diff --git a/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go b/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go
index c5c04d5b9..4056138e2 100644
--- a/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go
+++ b/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go
@@ -93,6 +93,11 @@ type ClusterExtensionSpec struct {
 	//
 	// +optional
 	Install *ClusterExtensionInstallConfig `json:"install,omitempty"`
+
+	// config contains arbitrary configuration values to be applied at render time.
+	// These values will be merged into the bundle manifests during rendering.
+	// +optional
+	Config map[string]interface{} `json:"config,omitempty"`
 }
 
 const SourceTypeCatalog = "Catalog"
diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
index 3fdbf943c..f6c632921 100644
--- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
+++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
@@ -14,6 +14,7 @@ import (
 	rbacv1 "k8s.io/api/rbac/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/utils/ptr"
@@ -281,6 +282,30 @@ func BundleAdditionalResourcesGenerator(rv1 *bundle.RegistryV1, opts render.Opti
 
 		objs = append(objs, obj)
 	}
+	// apply overrides from configuration, if present
+	if opts.Config != nil {
+		for _, obj := range objs {
+			if obj.GetObjectKind().GroupVersionKind().Kind == "ConfigMap" {
+				u, ok := obj.(*unstructured.Unstructured)
+				if !ok {
+					continue
+				}
+				data, found, err := unstructured.NestedStringMap(u.Object, "data")
+				if err != nil {
+					return nil, err
+				}
+				if !found {
+					data = map[string]string{}
+				}
+				for k, v := range opts.Config {
+					data[k] = fmt.Sprintf("%v", v)
+				}
+				if err := unstructured.SetNestedStringMap(u.Object, data, "data"); err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
 	return objs, nil
 }
 
diff --git a/internal/operator-controller/rukpak/render/render.go b/internal/operator-controller/rukpak/render/render.go
index 70063f1d4..9bf8827a3 100644
--- a/internal/operator-controller/rukpak/render/render.go
+++ b/internal/operator-controller/rukpak/render/render.go
@@ -61,6 +61,8 @@ type Options struct {
 	TargetNamespaces    []string
 	UniqueNameGenerator UniqueNameGenerator
 	CertificateProvider CertificateProvider
+	// Config holds arbitrary configuration values provided at render time
+	Config map[string]interface{}
 }
 
 func (o *Options) apply(opts ...Option) *Options {
@@ -94,6 +96,13 @@ func WithTargetNamespaces(namespaces ...string) Option {
 	}
 }
 
+// WithConfig supplies configuration values to be used during bundle rendering
+func WithConfig(cfg map[string]interface{}) Option {
+	return func(o *Options) {
+		o.Config = cfg
+	}
+}
+
 func WithUniqueNameGenerator(generator UniqueNameGenerator) Option {
 	return func(o *Options) {
 		o.UniqueNameGenerator = generator
diff --git a/test/convert/generate-manifests.go b/test/convert/generate-manifests.go
index 147b05e36..9b17fd380 100644
--- a/test/convert/generate-manifests.go
+++ b/test/convert/generate-manifests.go
@@ -68,8 +68,20 @@ func generateManifests(outputPath, bundleDir, installNamespace, watchNamespace s
 		os.Exit(1)
 	}
 
-	// Convert RegistryV1 to plain manifests
-	objs, err := registryv1.Renderer.Render(regv1, installNamespace, render.WithTargetNamespaces(watchNamespace))
+	// load optional configuration for registry+v1 bundle if present
+	cfg := map[string]interface{}{}
+	configFile := filepath.Join(bundleDir, "config.yaml")
+	if data, err := os.ReadFile(configFile); err == nil {
+		if err := yaml.Unmarshal(data, &cfg); err != nil {
+			return fmt.Errorf("error unmarshalling config file %q: %w", configFile, err)
+		}
+	}
+	// Convert RegistryV1 to plain manifests, applying any configuration
+	opts := []render.Option{render.WithTargetNamespaces(watchNamespace)}
+	if cfg != nil {
+		opts = append(opts, render.WithConfig(cfg))
+	}
+	objs, err := registryv1.Renderer.Render(regv1, installNamespace, opts...)
 	if err != nil {
 		return fmt.Errorf("error converting registry+v1 bundle: %w", err)
 	}
diff --git a/testdata/images/bundles/test-operator/v2.0.0/config.yaml b/testdata/images/bundles/test-operator/v2.0.0/config.yaml
new file mode 100644
index 000000000..2f74f9758
--- /dev/null
+++ b/testdata/images/bundles/test-operator/v2.0.0/config.yaml
@@ -0,0 +1,2 @@
+version: "v2.0.0-demo"
+name: "demo-configmap"

From 549afcf6f48fc3c6239d63cb214e9ee4ff0bec25 Mon Sep 17 00:00:00 2001
From: Brett Tofel <btofel@redhat.com>
Date: Thu, 31 Jul 2025 14:56:39 -0400
Subject: [PATCH 2/5] Add helm chart and value processing to demo

Signed-off-by: Brett Tofel <btofel@redhat.com>
---
 cmd/demo-config/main.go                       |  70 +++++++-
 .../charts/test-operator/v2.0.0/Chart.yaml    |   4 +
 .../charts/test-operator/v2.0.0/config.yaml   |   2 +
 .../v2.0.0/templates/configmap.yaml           |   7 +
 .../olm.operatorframework.com_olme2etest.yaml |  28 ++++
 .../testoperator.clusterserviceversion.yaml   | 151 ++++++++++++++++++
 .../templates/testoperator.networkpolicy.yaml |   8 +
 .../charts/test-operator/v2.0.0/values.yaml   |   2 +
 8 files changed, 268 insertions(+), 4 deletions(-)
 create mode 100644 testdata/images/charts/test-operator/v2.0.0/Chart.yaml
 create mode 100644 testdata/images/charts/test-operator/v2.0.0/config.yaml
 create mode 100644 testdata/images/charts/test-operator/v2.0.0/templates/configmap.yaml
 create mode 100644 testdata/images/charts/test-operator/v2.0.0/templates/olm.operatorframework.com_olme2etest.yaml
 create mode 100644 testdata/images/charts/test-operator/v2.0.0/templates/testoperator.clusterserviceversion.yaml
 create mode 100644 testdata/images/charts/test-operator/v2.0.0/templates/testoperator.networkpolicy.yaml
 create mode 100644 testdata/images/charts/test-operator/v2.0.0/values.yaml

diff --git a/cmd/demo-config/main.go b/cmd/demo-config/main.go
index 9578931c0..625915fd3 100644
--- a/cmd/demo-config/main.go
+++ b/cmd/demo-config/main.go
@@ -1,24 +1,32 @@
 package main
 
 import (
+	"flag"
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"sigs.k8s.io/yaml"
 
+	"helm.sh/helm/v3/pkg/chart/loader"
+	"helm.sh/helm/v3/pkg/chartutil"
+	"helm.sh/helm/v3/pkg/engine"
+
 	"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source"
 	"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render"
 	"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/registryv1"
 )
 
 func main() {
-	if len(os.Args) != 3 {
-		fmt.Fprintf(os.Stderr, "usage: %s <bundle-dir> <install-namespace>\n", os.Args[0])
+	helmFlag := flag.Bool("helm", false, "render as Helm chart instead of registry+v1 bundle")
+	flag.Parse()
+	if flag.NArg() != 2 {
+		fmt.Fprintf(os.Stderr, "usage: %s [-helm] <bundle-dir> <install-namespace>\n", os.Args[0])
 		os.Exit(1)
 	}
-	bundleDir := os.Args[1]
-	installNs := os.Args[2]
+	bundleDir := flag.Arg(0)
+	installNs := flag.Arg(1)
 
 	// load optional configuration values from config.yaml
 	cfg := map[string]interface{}{}
@@ -30,6 +38,60 @@ func main() {
 		}
 	}
 
+	if *helmFlag {
+		// ensure a Helm chart is present
+		// look for Chart.yaml in the specified dir, or try replacing "bundles" -> "charts" if not found
+		chartPath := filepath.Join(bundleDir, "Chart.yaml")
+		if _, err := os.Stat(chartPath); err != nil {
+			// fallback: swap a "bundles" segment to "charts" in the path
+			parts := strings.Split(bundleDir, string(os.PathSeparator))
+			for i, p := range parts {
+				if p == "bundles" {
+					parts[i] = "charts"
+					altDir := filepath.Join(parts...)
+					if _, err2 := os.Stat(filepath.Join(altDir, "Chart.yaml")); err2 == nil {
+						bundleDir = altDir
+						chartPath = filepath.Join(bundleDir, "Chart.yaml")
+						break
+					}
+				}
+			}
+			if _, err := os.Stat(chartPath); err != nil {
+				fmt.Fprintf(os.Stderr, "error: helm chart not found in %q: %v\n", bundleDir, err)
+				os.Exit(1)
+			}
+		}
+		// render with the Helm engine
+		chrt, err := loader.Load(bundleDir)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "error loading Helm chart: %v\n", err)
+			os.Exit(1)
+		}
+		// load values.yaml and apply any config overrides
+		values := map[string]interface{}{}
+		valuesFile := filepath.Join(bundleDir, "values.yaml")
+		if data, err := os.ReadFile(valuesFile); err == nil {
+			if err := yaml.Unmarshal(data, &values); err != nil {
+				fmt.Fprintf(os.Stderr, "error unmarshalling values file %q: %v\n", valuesFile, err)
+				os.Exit(1)
+			}
+		}
+		for k, v := range cfg {
+			values[k] = v
+		}
+		// render the chart templates using the provided values under the 'Values' key
+		renderContext := chartutil.Values{"Values": values}
+		rendered, err := engine.Engine{}.Render(chrt, renderContext)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "error rendering Helm chart: %v\n", err)
+			os.Exit(1)
+		}
+		for name, content := range rendered {
+			fmt.Printf("---\n# Source: %s\n%s\n", name, content)
+		}
+		return
+	}
+
 	// parse registry+v1 bundle
 	regv1, err := source.FromFS(os.DirFS(bundleDir)).GetBundle()
 	if err != nil {
diff --git a/testdata/images/charts/test-operator/v2.0.0/Chart.yaml b/testdata/images/charts/test-operator/v2.0.0/Chart.yaml
new file mode 100644
index 000000000..ca6a6c394
--- /dev/null
+++ b/testdata/images/charts/test-operator/v2.0.0/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v2
+name:        test-operator
+version:     2.0.0
+appVersion:  v2.0.0
diff --git a/testdata/images/charts/test-operator/v2.0.0/config.yaml b/testdata/images/charts/test-operator/v2.0.0/config.yaml
new file mode 100644
index 000000000..15bbf424e
--- /dev/null
+++ b/testdata/images/charts/test-operator/v2.0.0/config.yaml
@@ -0,0 +1,2 @@
+version: "v2.0.0-demo"
+name:    "demo-configmap"
diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/configmap.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/configmap.yaml
new file mode 100644
index 000000000..b8ad79366
--- /dev/null
+++ b/testdata/images/charts/test-operator/v2.0.0/templates/configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: test-configmap
+data:
+  version: "{{ index .Values "version" }}"
+  name:    "{{ index .Values "name" }}"
diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/olm.operatorframework.com_olme2etest.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/olm.operatorframework.com_olme2etest.yaml
new file mode 100644
index 000000000..fcfd4aeaf
--- /dev/null
+++ b/testdata/images/charts/test-operator/v2.0.0/templates/olm.operatorframework.com_olme2etest.yaml
@@ -0,0 +1,28 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.1
+  name: olme2etests.olm.operatorframework.io
+spec:
+  group: olm.operatorframework.io
+  names:
+    kind: OLME2ETest
+    listKind: OLME2ETestList
+    plural: olme2etests
+    singular: olme2etest
+  scope: Cluster
+  versions:
+    - name: v1
+      served: true
+      storage: true
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              properties:
+                testField:
+                  type: string
diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.clusterserviceversion.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.clusterserviceversion.yaml
new file mode 100644
index 000000000..7a06196f2
--- /dev/null
+++ b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.clusterserviceversion.yaml
@@ -0,0 +1,151 @@
+apiVersion: operators.coreos.com/v1alpha1
+kind: ClusterServiceVersion
+metadata:
+  annotations:
+    alm-examples: |-
+      [
+        {
+          "apiVersion": "olme2etests.olm.operatorframework.io/v1",
+          "kind": "OLME2ETests",
+          "metadata": {
+            "labels": {
+              "app.kubernetes.io/managed-by": "kustomize",
+              "app.kubernetes.io/name": "test"
+            },
+            "name": "test-sample"
+          },
+          "spec": null
+        }
+      ]
+    capabilities: Basic Install
+    createdAt: "2024-10-24T19:21:40Z"
+    operators.operatorframework.io/builder: operator-sdk-v1.34.1
+    operators.operatorframework.io/project_layout: go.kubebuilder.io/v4
+  name: testoperator.v2.0.0
+  namespace: placeholder
+spec:
+  apiservicedefinitions: {}
+  customresourcedefinitions:
+  owned:
+    - description: Configures subsections of Alertmanager configuration specific to each namespace
+      displayName: OLME2ETest
+      kind: OLME2ETest
+      name: olme2etests.olm.operatorframework.io
+      version: v1
+  description: OLM E2E Testing Operator
+  displayName: test-operator
+  icon:
+  - base64data: ""
+    mediatype: ""
+  install:
+    spec:
+      deployments:
+      - label:
+          app.kubernetes.io/component: controller
+          app.kubernetes.io/name: test-operator
+          app.kubernetes.io/version: 2.0.0
+        name: test-operator
+        spec:
+          replicas: 1
+          selector:
+            matchLabels:
+              app: olme2etest
+          template:
+            metadata:
+              labels:
+                app: olme2etest
+            spec:
+              terminationGracePeriodSeconds: 0
+              containers:
+              - name: busybox
+                image: busybox
+                command:
+                - 'sleep'
+                - '1000'
+                securityContext:
+                  runAsUser: 1000
+                  runAsNonRoot: true
+                serviceAccountName: simple-bundle-manager
+      clusterPermissions:
+      - rules:
+        - apiGroups:
+          - authentication.k8s.io
+          resources:
+          - tokenreviews
+          verbs:
+          - create
+        - apiGroups:
+          - authorization.k8s.io
+          resources:
+          - subjectaccessreviews
+          verbs:
+          - create
+        serviceAccountName: simple-bundle-manager
+      permissions:
+      - rules:
+        - apiGroups:
+          - ""
+          resources:
+          - configmaps
+          - serviceaccounts
+          verbs:
+          - get
+          - list
+          - watch
+          - create
+          - update
+          - patch
+          - delete
+        - apiGroups:
+          - networking.k8s.io
+          resources:
+          - networkpolicies
+          verbs:
+          - get
+          - list
+          - create
+          - update
+          - delete
+        - apiGroups:
+          - coordination.k8s.io
+          resources:
+          - leases
+          verbs:
+          - get
+          - list
+          - watch
+          - create
+          - update
+          - patch
+          - delete
+        - apiGroups:
+          - ""
+          resources:
+          - events
+          verbs:
+          - create
+          - patch
+        serviceAccountName: simple-bundle-manager
+    strategy: deployment
+  installModes:
+  - supported: false
+    type: OwnNamespace
+  - supported: false
+    type: SingleNamespace
+  - supported: false
+    type: MultiNamespace
+  - supported: true
+    type: AllNamespaces
+  keywords:
+  - registry
+  links:
+  - name: simple-bundle
+    url: https://simple-bundle.domain
+  maintainers:
+  - email: main#simple-bundle.domain
+    name: Simple Bundle
+  maturity: beta
+  provider:
+    name: Simple Bundle
+    url: https://simple-bundle.domain
+  version: 2.0.0
diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.networkpolicy.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.networkpolicy.yaml
new file mode 100644
index 000000000..d87648e6f
--- /dev/null
+++ b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.networkpolicy.yaml
@@ -0,0 +1,8 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: test-operator-network-policy
+spec:
+  podSelector: {}
+  policyTypes:
+  - Ingress
diff --git a/testdata/images/charts/test-operator/v2.0.0/values.yaml b/testdata/images/charts/test-operator/v2.0.0/values.yaml
new file mode 100644
index 000000000..547b9d31a
--- /dev/null
+++ b/testdata/images/charts/test-operator/v2.0.0/values.yaml
@@ -0,0 +1,2 @@
+version: "v2.0.0"
+name:    "test-configmap"

From 8dda6f846c21f2b4d0ce171dfc3b8659bae23ba4 Mon Sep 17 00:00:00 2001
From: Brett Tofel <btofel@redhat.com>
Date: Fri, 1 Aug 2025 08:48:44 -0400
Subject: [PATCH 3/5] Make helm applier use the values

Signed-off-by: Brett Tofel <btofel@redhat.com>
---
 internal/operator-controller/applier/helm.go | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go
index ecfb3fdc2..0653c4fbb 100644
--- a/internal/operator-controller/applier/helm.go
+++ b/internal/operator-controller/applier/helm.go
@@ -126,7 +126,8 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte
 	if err != nil {
 		return nil, "", err
 	}
-	values := chartutil.Values{}
+	// merge in any user-provided config values as Helm values
+	values := chartutil.Values{"Values": ext.Spec.Config}
 
 	post := &postrenderer{
 		labels: objectLabels,

From 36f900ab4cdef8499c621b43f6e8fd3418c99611 Mon Sep 17 00:00:00 2001
From: Brett Tofel <btofel@redhat.com>
Date: Fri, 1 Aug 2025 09:56:08 -0400
Subject: [PATCH 4/5] Make helm applier deal in JSON since Interface no

Signed-off-by: Brett Tofel <btofel@redhat.com>
---
 api/v1/clusterextension_types.go             |  7 +++++--
 internal/operator-controller/applier/helm.go | 11 +++++++++--
 2 files changed, 14 insertions(+), 4 deletions(-)

diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go
index 3f43f7ccd..c7a7ef62c 100644
--- a/api/v1/clusterextension_types.go
+++ b/api/v1/clusterextension_types.go
@@ -17,6 +17,7 @@ limitations under the License.
 package v1
 
 import (
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -93,10 +94,12 @@ type ClusterExtensionSpec struct {
 	// +optional
 	Install *ClusterExtensionInstallConfig `json:"install,omitempty"`
 
-	// config contains arbitrary configuration values to be applied at render time.
+	// config contains arbitrary JSON configuration values to be applied at render time.
 	// These values will be merged into the bundle manifests during rendering.
 	// +optional
-	Config map[string]interface{} `json:"config,omitempty"`
+	// +kubebuilder:validation:Type=object
+	// +kubebuilder:pruning:PreserveUnknownFields
+	Config *apiextensionsv1.JSON `json:"config,omitempty"`
 }
 
 const SourceTypeCatalog = "Catalog"
diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go
index 0653c4fbb..c6ecb68b2 100644
--- a/internal/operator-controller/applier/helm.go
+++ b/internal/operator-controller/applier/helm.go
@@ -3,6 +3,7 @@ package applier
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -126,8 +127,14 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte
 	if err != nil {
 		return nil, "", err
 	}
-	// merge in any user-provided config values as Helm values
-	values := chartutil.Values{"Values": ext.Spec.Config}
+	// merge in any user-provided config JSON as Helm values
+	valuesMap := map[string]interface{}{}
+	if ext.Spec.Config != nil && ext.Spec.Config.Raw != nil {
+		if err := json.Unmarshal(ext.Spec.Config.Raw, &valuesMap); err != nil {
+			return nil, "", fmt.Errorf("invalid JSON in spec.config: %w", err)
+		}
+	}
+	values := chartutil.Values{"Values": valuesMap}
 
 	post := &postrenderer{
 		labels: objectLabels,

From 1bccb4546712980c9e8d10ec65ef74303424af42 Mon Sep 17 00:00:00 2001
From: Brett Tofel <btofel@redhat.com>
Date: Mon, 4 Aug 2025 14:34:00 -0400
Subject: [PATCH 5/5] Manifest changes associated w/ spec.config add

Signed-off-by: Brett Tofel <btofel@redhat.com>
---
 api/v1/zz_generated.deepcopy.go                          | 4 ++++
 .../olm.operatorframework.io_clusterextensions.yaml      | 6 ++++++
 .../olm.operatorframework.io_clusterextensions.yaml      | 6 ++++++
 docs/tutorials/install-extension.md                      | 9 ++++++---
 manifests/experimental-e2e.yaml                          | 6 ++++++
 manifests/experimental.yaml                              | 6 ++++++
 manifests/standard-e2e.yaml                              | 6 ++++++
 manifests/standard.yaml                                  | 6 ++++++
 8 files changed, 46 insertions(+), 3 deletions(-)

diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 23fcf7d85..f0c98bc90 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -330,6 +330,10 @@ func (in *ClusterExtensionSpec) DeepCopyInto(out *ClusterExtensionSpec) {
 		*out = new(ClusterExtensionInstallConfig)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.Config != nil {
+		in, out := &in.Config, &out.Config
+		*out = (*in).DeepCopy()
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionSpec.
diff --git a/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml b/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
index 162683603..eef8d5195 100644
--- a/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
+++ b/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
@@ -57,6 +57,12 @@ spec:
             description: spec is an optional field that defines the desired state
               of the ClusterExtension.
             properties:
+              config:
+                description: |-
+                  config contains arbitrary JSON configuration values to be applied at render time.
+                  These values will be merged into the bundle manifests during rendering.
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
               install:
                 description: |-
                   install is an optional field used to configure the installation options
diff --git a/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml b/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
index 18faa5978..91fe222ec 100644
--- a/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
+++ b/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
@@ -57,6 +57,12 @@ spec:
             description: spec is an optional field that defines the desired state
               of the ClusterExtension.
             properties:
+              config:
+                description: |-
+                  config contains arbitrary JSON configuration values to be applied at render time.
+                  These values will be merged into the bundle manifests during rendering.
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
               install:
                 description: |-
                   install is an optional field used to configure the installation options
diff --git a/docs/tutorials/install-extension.md b/docs/tutorials/install-extension.md
index b71d5a58c..72a2f689f 100644
--- a/docs/tutorials/install-extension.md
+++ b/docs/tutorials/install-extension.md
@@ -31,9 +31,9 @@ For information on determining the ServiceAccount's permission, please see [Deri
 
 ## Procedure
 
-1. Create a CR for the Kubernetes extension you want to install:
+1. Create a CR for the Kubernetes extension you want to install. You can also specify arbitrary configuration values under `spec.config` (per [RFC: Registry+v1 Configuration Support](../../RFC_Config_registry+v1_bundle_config.md)):
 
-    ``` yaml title="Example CR"
+    ```yaml title="Example CR"
     apiVersion: olm.operatorframework.io/v1
     kind: ClusterExtension
     metadata:
@@ -46,8 +46,11 @@ For information on determining the ServiceAccount's permission, please see [Deri
         sourceType: Catalog
         catalog:
           packageName: <package_name>
-          channels: [<channel1>,<channel2]
+          channels: [<channel1>,<channel2>]
           version: "<version>"
+      config:
+        version: "v2.0.0-demo"
+        name:    "demo-configmap"
     ```
 
      `extension_name`
diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml
index d3adf46e5..a05e2ddc7 100644
--- a/manifests/experimental-e2e.yaml
+++ b/manifests/experimental-e2e.yaml
@@ -511,6 +511,12 @@ spec:
             description: spec is an optional field that defines the desired state
               of the ClusterExtension.
             properties:
+              config:
+                description: |-
+                  config contains arbitrary JSON configuration values to be applied at render time.
+                  These values will be merged into the bundle manifests during rendering.
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
               install:
                 description: |-
                   install is an optional field used to configure the installation options
diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml
index 7b0d2b9a3..d9929fa1c 100644
--- a/manifests/experimental.yaml
+++ b/manifests/experimental.yaml
@@ -511,6 +511,12 @@ spec:
             description: spec is an optional field that defines the desired state
               of the ClusterExtension.
             properties:
+              config:
+                description: |-
+                  config contains arbitrary JSON configuration values to be applied at render time.
+                  These values will be merged into the bundle manifests during rendering.
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
               install:
                 description: |-
                   install is an optional field used to configure the installation options
diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml
index a8aff9838..fb19efa4c 100644
--- a/manifests/standard-e2e.yaml
+++ b/manifests/standard-e2e.yaml
@@ -511,6 +511,12 @@ spec:
             description: spec is an optional field that defines the desired state
               of the ClusterExtension.
             properties:
+              config:
+                description: |-
+                  config contains arbitrary JSON configuration values to be applied at render time.
+                  These values will be merged into the bundle manifests during rendering.
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
               install:
                 description: |-
                   install is an optional field used to configure the installation options
diff --git a/manifests/standard.yaml b/manifests/standard.yaml
index fa2546305..0d41c80f8 100644
--- a/manifests/standard.yaml
+++ b/manifests/standard.yaml
@@ -511,6 +511,12 @@ spec:
             description: spec is an optional field that defines the desired state
               of the ClusterExtension.
             properties:
+              config:
+                description: |-
+                  config contains arbitrary JSON configuration values to be applied at render time.
+                  These values will be merged into the bundle manifests during rendering.
+                type: object
+                x-kubernetes-preserve-unknown-fields: true
               install:
                 description: |-
                   install is an optional field used to configure the installation options
