From bc1a19e95a2e6db497167ab04ae51010796d19e2 Mon Sep 17 00:00:00 2001 From: Per Goncalves da Silva Date: Tue, 26 Aug 2025 17:54:24 +0200 Subject: [PATCH 1/5] Extend ClusterExtension API with .spec.config Signed-off-by: Per Goncalves da Silva --- api/v1/clusterextension_types.go | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index 0141f1a7a..a2dd890c3 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" ) @@ -25,6 +26,8 @@ var ClusterExtensionKind = "ClusterExtension" type ( UpgradeConstraintPolicy string CRDUpgradeSafetyEnforcement string + + ClusterExtensionConfigType string ) const ( @@ -39,6 +42,8 @@ const ( // Use with caution as this can lead to unknown and potentially // disastrous results such as data loss. UpgradeConstraintPolicySelfCertified UpgradeConstraintPolicy = "SelfCertified" + + ClusterExtensionConfigTypeInline ClusterExtensionConfigType = "Inline" ) // ClusterExtensionSpec defines the desired state of ClusterExtension @@ -92,6 +97,15 @@ type ClusterExtensionSpec struct { // // +optional Install *ClusterExtensionInstallConfig `json:"install,omitempty"` + + // config contains optional configuration values applied during rendering of the + // ClusterExtension's manifests. Values can be specified inline. + // + // config is optional. When not specified, the default configuration of the resolved bundle will be used. + // + // + // +optional + Config *ClusterExtensionConfig `json:"config,omitempty"` } const SourceTypeCatalog = "Catalog" @@ -138,6 +152,34 @@ type ClusterExtensionInstallConfig struct { Preflight *PreflightConfig `json:"preflight,omitempty"` } +// ClusterExtensionConfig is a discriminated union which selects the source configuration values to be merged into +// the ClusterExtension's rendered manifests. +// +// +kubebuilder:validation:XValidation:rule="has(self.configType) && self.configType == 'Inline' ?has(self.inline) : !has(self.inline)",message="inline is required when configType is Inline, and forbidden otherwise" +// +union +type ClusterExtensionConfig struct { + // configType is a required reference to the type of configuration source. + // + // Allowed values are "Inline" + // + // When this field is set to "Inline", the cluster extension configuration is defined inline within the + // ClusterExtension resource. + // + // +unionDiscriminator + // +kubebuilder:validation:Enum:="Inline" + // +kubebuilder:validation:Required + ConfigType ClusterExtensionConfigType `json:"configType"` + + // inline contains JSON or YAML values specified directly in the + // ClusterExtension. + // + // inline must be set if configType is 'Inline'. + // + // +kubebuilder:validation:Type=object + // +optional + Inline *apiextensionsv1.JSON `json:"inline,omitempty"` +} + // CatalogFilter defines the attributes used to identify and filter content from a catalog. type CatalogFilter struct { // packageName is a reference to the name of the package to be installed From ac0290c4681280456fe9e587f47cfeb3ef322871 Mon Sep 17 00:00:00 2001 From: Per Goncalves da Silva Date: Tue, 26 Aug 2025 17:54:49 +0200 Subject: [PATCH 2/5] Generate manifests Signed-off-by: Per Goncalves da Silva --- api/v1/zz_generated.deepcopy.go | 24 +++++++++++++ ...peratorframework.io_clusterextensions.yaml | 34 +++++++++++++++++++ manifests/experimental-e2e.yaml | 34 +++++++++++++++++++ manifests/experimental.yaml | 34 +++++++++++++++++++ 4 files changed, 126 insertions(+) diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 23fcf7d85..01ad99562 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -252,6 +252,25 @@ func (in *ClusterExtension) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterExtensionConfig) DeepCopyInto(out *ClusterExtensionConfig) { + *out = *in + if in.Inline != nil { + in, out := &in.Inline, &out.Inline + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionConfig. +func (in *ClusterExtensionConfig) DeepCopy() *ClusterExtensionConfig { + if in == nil { + return nil + } + out := new(ClusterExtensionConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterExtensionInstallConfig) DeepCopyInto(out *ClusterExtensionInstallConfig) { *out = *in @@ -330,6 +349,11 @@ func (in *ClusterExtensionSpec) DeepCopyInto(out *ClusterExtensionSpec) { *out = new(ClusterExtensionInstallConfig) (*in).DeepCopyInto(*out) } + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(ClusterExtensionConfig) + (*in).DeepCopyInto(*out) + } } // 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..ac24fe1b6 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,40 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains optional configuration values applied during rendering of the + ClusterExtension's manifests. Values can be specified inline. + + config is optional. When not specified, the default configuration of the resolved bundle will be used. + properties: + configType: + description: |- + configType is a required reference to the type of configuration source. + + Allowed values are "Inline" + + When this field is set to "Inline", the cluster extension configuration is defined inline within the + ClusterExtension resource. + enum: + - Inline + type: string + inline: + description: |- + inline contains JSON or YAML values specified directly in the + ClusterExtension. + + inline must be set if configType is 'Inline'. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - configType + type: object + x-kubernetes-validations: + - message: inline is required when configType is Inline, and forbidden + otherwise + rule: 'has(self.configType) && self.configType == ''Inline'' ?has(self.inline) + : !has(self.inline)' install: description: |- install is an optional field used to configure the installation options diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index a91833bd7..36ef3661a 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -511,6 +511,40 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains optional configuration values applied during rendering of the + ClusterExtension's manifests. Values can be specified inline. + + config is optional. When not specified, the default configuration of the resolved bundle will be used. + properties: + configType: + description: |- + configType is a required reference to the type of configuration source. + + Allowed values are "Inline" + + When this field is set to "Inline", the cluster extension configuration is defined inline within the + ClusterExtension resource. + enum: + - Inline + type: string + inline: + description: |- + inline contains JSON or YAML values specified directly in the + ClusterExtension. + + inline must be set if configType is 'Inline'. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - configType + type: object + x-kubernetes-validations: + - message: inline is required when configType is Inline, and forbidden + otherwise + rule: 'has(self.configType) && self.configType == ''Inline'' ?has(self.inline) + : !has(self.inline)' install: description: |- install is an optional field used to configure the installation options diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 00dc14153..291f34cff 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -511,6 +511,40 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains optional configuration values applied during rendering of the + ClusterExtension's manifests. Values can be specified inline. + + config is optional. When not specified, the default configuration of the resolved bundle will be used. + properties: + configType: + description: |- + configType is a required reference to the type of configuration source. + + Allowed values are "Inline" + + When this field is set to "Inline", the cluster extension configuration is defined inline within the + ClusterExtension resource. + enum: + - Inline + type: string + inline: + description: |- + inline contains JSON or YAML values specified directly in the + ClusterExtension. + + inline must be set if configType is 'Inline'. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - configType + type: object + x-kubernetes-validations: + - message: inline is required when configType is Inline, and forbidden + otherwise + rule: 'has(self.configType) && self.configType == ''Inline'' ?has(self.inline) + : !has(self.inline)' install: description: |- install is an optional field used to configure the installation options From 211786d48f22bba92e6d5a68671b56c93c0d5e8d Mon Sep 17 00:00:00 2001 From: Per Goncalves da Silva Date: Tue, 26 Aug 2025 17:55:01 +0200 Subject: [PATCH 3/5] Update docs Signed-off-by: Per Goncalves da Silva --- docs/api-reference/olmv1-api-reference.md | 35 +++++++++++++++++++ .../howto/single-ownnamespace-install.md | 16 +++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 84fdbfa64..8aaa37e84 100644 --- a/docs/api-reference/olmv1-api-reference.md +++ b/docs/api-reference/olmv1-api-reference.md @@ -239,6 +239,40 @@ _Appears in:_ | `status` _[ClusterExtensionStatus](#clusterextensionstatus)_ | status is an optional field that defines the observed state of the ClusterExtension. | | | +#### ClusterExtensionConfig + + + +ClusterExtensionConfig is a discriminated union which selects the source configuration values to be merged into +the ClusterExtension's rendered manifests. + + + +_Appears in:_ +- [ClusterExtensionSpec](#clusterextensionspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `configType` _[ClusterExtensionConfigType](#clusterextensionconfigtype)_ | configType is a required reference to the type of configuration source.

Allowed values are "Inline"

When this field is set to "Inline", the cluster extension configuration is defined inline within the
ClusterExtension resource. | | Enum: [Inline]
Required: \{\}
| +| `inline` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#json-v1-apiextensions-k8s-io)_ | inline contains JSON or YAML values specified directly in the
ClusterExtension.

inline must be set if configType is 'Inline'. | | Type: object
| + + +#### ClusterExtensionConfigType + +_Underlying type:_ _string_ + + + + + +_Appears in:_ +- [ClusterExtensionConfig](#clusterextensionconfig) + +| Field | Description | +| --- | --- | +| `Inline` | | + + #### ClusterExtensionInstallConfig @@ -309,6 +343,7 @@ _Appears in:_ | `serviceAccount` _[ServiceAccountReference](#serviceaccountreference)_ | serviceAccount is a reference to a ServiceAccount used to perform all interactions
with the cluster that are required to manage the extension.
The ServiceAccount must be configured with the necessary permissions to perform these interactions.
The ServiceAccount must exist in the namespace referenced in the spec.
serviceAccount is required. | | Required: \{\}
| | `source` _[SourceConfig](#sourceconfig)_ | source is a required field which selects the installation source of content
for this ClusterExtension. Selection is performed by setting the sourceType.

Catalog is currently the only implemented sourceType, and setting the
sourcetype to "Catalog" requires the catalog field to also be defined.

Below is a minimal example of a source definition (in yaml):

source:
sourceType: Catalog
catalog:
packageName: example-package | | Required: \{\}
| | `install` _[ClusterExtensionInstallConfig](#clusterextensioninstallconfig)_ | install is an optional field used to configure the installation options
for the ClusterExtension such as the pre-flight check configuration. | | | +| `config` _[ClusterExtensionConfig](#clusterextensionconfig)_ | config contains optional configuration values applied during rendering of the
ClusterExtension's manifests. Values can be specified inline.

config is optional. When not specified, the default configuration of the resolved bundle will be used.

| | | #### ClusterExtensionStatus diff --git a/docs/draft/howto/single-ownnamespace-install.md b/docs/draft/howto/single-ownnamespace-install.md index 2f440f32d..866bf1da5 100644 --- a/docs/draft/howto/single-ownnamespace-install.md +++ b/docs/draft/howto/single-ownnamespace-install.md @@ -56,11 +56,11 @@ kubectl rollout status -n olmv1-system deployment/operator-controller-controller ## Configuring the `ClusterExtension` A `ClusterExtension` can be configured to install bundle in `Single-` or `OwnNamespace` mode through the -`olm.operatorframework.io/watch-namespace: ` annotation. The *installMode* is inferred in the following way: +`.spec.config.inline.watchNamespace` property. The *installMode* is inferred in the following way: - - *AllNamespaces*: `` is empty, or the annotation is not present - - *OwnNamespace*: `` is the install namespace (i.e. `.spec.namespace`) - - *SingleNamespace*: `` not the install namespace + - *AllNamespaces*: `watchNamespace` is empty, or not set + - *OwnNamespace*: `watchNamespace` is the install namespace (i.e. `.spec.namespace`) + - *SingleNamespace*: `watchNamespace` *not* the install namespace ### Examples @@ -70,12 +70,13 @@ apiVersion: olm.operatorframework.io/v1 kind: ClusterExtension metadata: name: argocd - annotations: - olm.operatorframework.io/watch-namespace: argocd-watch spec: namespace: argocd serviceAccount: name: argocd-installer + config: + inline: + watchNamespace: argocd-watch source: sourceType: Catalog catalog: @@ -96,6 +97,9 @@ spec: namespace: argocd serviceAccount: name: argocd-installer + config: + inline: + watchNamespace: argocd source: sourceType: Catalog catalog: From 93518ff43cb601cb0e92ff44bad1dee744d8235d Mon Sep 17 00:00:00 2001 From: Per Goncalves da Silva Date: Tue, 26 Aug 2025 17:56:23 +0200 Subject: [PATCH 4/5] Update applier to take watchNamespace configuration from the extension Signed-off-by: Per Goncalves da Silva --- .../operator-controller/applier/helm_test.go | 11 +- .../applier/watchnamespace.go | 32 +++-- .../applier/watchnamespace_test.go | 134 +++++++++++++++--- 3 files changed, 143 insertions(+), 34 deletions(-) diff --git a/internal/operator-controller/applier/helm_test.go b/internal/operator-controller/applier/helm_test.go index 89c94df88..9601d2cbc 100644 --- a/internal/operator-controller/applier/helm_test.go +++ b/internal/operator-controller/applier/helm_test.go @@ -3,6 +3,7 @@ package applier_test import ( "context" "errors" + "fmt" "io" "os" "testing" @@ -16,6 +17,7 @@ import ( "helm.sh/helm/v3/pkg/storage/driver" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" featuregatetesting "k8s.io/component-base/featuregate/testing" "sigs.k8s.io/controller-runtime/pkg/client" @@ -567,8 +569,13 @@ func TestApply_InstallationWithSingleOwnNamespaceInstallSupportEnabled(t *testin testExt := &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: "testExt", - Annotations: map[string]string{ - applier.AnnotationClusterExtensionWatchNamespace: expectedWatchNamespace, + }, + Spec: ocv1.ClusterExtensionSpec{ + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(fmt.Sprintf(`{"watchNamespace":"%s"}`, expectedWatchNamespace)), + }, }, }, } diff --git a/internal/operator-controller/applier/watchnamespace.go b/internal/operator-controller/applier/watchnamespace.go index 193f456b3..a89c95d29 100644 --- a/internal/operator-controller/applier/watchnamespace.go +++ b/internal/operator-controller/applier/watchnamespace.go @@ -1,9 +1,9 @@ package applier import ( + "encoding/json" "fmt" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/validation" ocv1 "github.com/operator-framework/operator-controller/api/v1" @@ -19,14 +19,28 @@ const ( // for registry+v1 bundles. This will go away once the ClusterExtension API is updated to include // (opaque) runtime configuration. func GetWatchNamespace(ext *ocv1.ClusterExtension) (string, error) { - if features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport) { - if ext != nil && ext.Annotations[AnnotationClusterExtensionWatchNamespace] != "" { - watchNamespace := ext.Annotations[AnnotationClusterExtensionWatchNamespace] - if errs := validation.IsDNS1123Subdomain(watchNamespace); len(errs) > 0 { - return "", fmt.Errorf("invalid watch namespace '%s': namespace must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character", watchNamespace) - } - return ext.Annotations[AnnotationClusterExtensionWatchNamespace], nil + if !features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport) { + return "", nil + } + + var watchNamespace string + if ext.Spec.Config != nil && ext.Spec.Config.Inline != nil { + cfg := struct { + WatchNamespace string `json:"watchNamespace"` + }{} + if err := json.Unmarshal(ext.Spec.Config.Inline.Raw, &cfg); err != nil { + return "", fmt.Errorf("invalid bundle configuration: %w", err) } + watchNamespace = cfg.WatchNamespace + } else if _, ok := ext.Annotations[AnnotationClusterExtensionWatchNamespace]; ok { + watchNamespace = ext.Annotations[AnnotationClusterExtensionWatchNamespace] + } else { + return "", nil } - return corev1.NamespaceAll, nil + + if errs := validation.IsDNS1123Subdomain(watchNamespace); len(errs) > 0 { + return "", fmt.Errorf("invalid watch namespace '%s': namespace must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character", watchNamespace) + } + + return watchNamespace, nil } diff --git a/internal/operator-controller/applier/watchnamespace_test.go b/internal/operator-controller/applier/watchnamespace_test.go index 90e018dc7..4745f3dc5 100644 --- a/internal/operator-controller/applier/watchnamespace_test.go +++ b/internal/operator-controller/applier/watchnamespace_test.go @@ -5,26 +5,31 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" featuregatetesting "k8s.io/component-base/featuregate/testing" - v1 "github.com/operator-framework/operator-controller/api/v1" + ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "github.com/operator-framework/operator-controller/internal/operator-controller/features" ) func TestGetWatchNamespacesWhenFeatureGateIsDisabled(t *testing.T) { - watchNamespace, err := applier.GetWatchNamespace(&v1.ClusterExtension{ + watchNamespace, err := applier.GetWatchNamespace(&ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: "extension", - Annotations: map[string]string{ - "olm.operatorframework.io/watch-namespace": "watch-namespace", + }, + Spec: ocv1.ClusterExtensionSpec{ + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(`{"watchNamespace":"watch-namespace"}`), + }, }, }, - Spec: v1.ClusterExtensionSpec{}, }) require.NoError(t, err) - t.Log("Check watchNamespace is '' even if the annotation is set") + t.Log("Check watchNamespace is '' even if the configuration is set") require.Equal(t, corev1.NamespaceAll, watchNamespace) } @@ -34,57 +39,140 @@ func TestGetWatchNamespace(t *testing.T) { for _, tt := range []struct { name string want string - csv *v1.ClusterExtension + csv *ocv1.ClusterExtension expectError bool }{ { - name: "cluster extension does not have watch namespace annotation", + name: "cluster extension does not configure a watch namespace", want: corev1.NamespaceAll, - csv: &v1.ClusterExtension{ + csv: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: "extension", Annotations: nil, }, - Spec: v1.ClusterExtensionSpec{}, + Spec: ocv1.ClusterExtensionSpec{}, + }, + expectError: false, + }, { + name: "cluster extension configures a watch namespace", + want: "watch-namespace", + csv: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(`{"watchNamespace":"watch-namespace"}`), + }, + }, + }, }, expectError: false, }, { - name: "cluster extension has valid namespace annotation", + name: "cluster extension configures a watch namespace through annotation", want: "watch-namespace", - csv: &v1.ClusterExtension{ + csv: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: "extension", Annotations: map[string]string{ "olm.operatorframework.io/watch-namespace": "watch-namespace", }, }, - Spec: v1.ClusterExtensionSpec{}, }, expectError: false, }, { - name: "cluster extension has invalid namespace annotation: multiple watch namespaces", - want: "", - csv: &v1.ClusterExtension{ + name: "cluster extension configures a watch namespace through annotation with invalid ns", + csv: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: "extension", Annotations: map[string]string{ - "olm.operatorframework.io/watch-namespace": "watch-namespace,watch-namespace2,watch-namespace3", + "olm.operatorframework.io/watch-namespace": "watch-namespace-", }, }, - Spec: v1.ClusterExtensionSpec{}, }, expectError: true, }, { - name: "cluster extension has invalid namespace annotation: invalid name", - want: "", - csv: &v1.ClusterExtension{ + name: "cluster extension configures a watch namespace through annotation with empty ns", + csv: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Name: "extension", Annotations: map[string]string{ - "olm.operatorframework.io/watch-namespace": "watch-namespace-", + "olm.operatorframework.io/watch-namespace": "", + }, + }, + }, + expectError: true, + }, { + name: "cluster extension configures a watch namespace through annotation and config (take config)", + want: "watch-namespace", + csv: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "extension", + Annotations: map[string]string{ + "olm.operatorframework.io/watch-namespace": "dont-use-this-watch-namespace", + }, + }, + Spec: ocv1.ClusterExtensionSpec{ + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(`{"watchNamespace":"watch-namespace"}`), + }, + }, + }, + }, + expectError: false, + }, { + name: "cluster extension configures an invalid watchNamespace: multiple watch namespaces", + want: "", + csv: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(`{"watchNamespace":"watch-namespace,watch-namespace2,watch-namespace3"}`), + }, + }, + }, + }, + expectError: true, + }, { + name: "cluster extension configures an invalid watchNamespace: invalid name", + want: "", + csv: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(`{"watchNamespace":"watch-namespace-"}`), + }, + }, + }, + }, + expectError: true, + }, { + name: "cluster extension configures an invalid watchNamespace: invalid json", + want: "", + csv: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(`invalid json`), + }, }, }, - Spec: v1.ClusterExtensionSpec{}, }, expectError: true, }, From 6ebde8959118f353583609be5636b94ce33b9b2c Mon Sep 17 00:00:00 2001 From: Per Goncalves da Silva Date: Tue, 26 Aug 2025 17:56:34 +0200 Subject: [PATCH 5/5] Add e2e test Signed-off-by: Per Goncalves da Silva --- .../experimental-e2e/experimental_e2e_test.go | 141 ++++++++++++++++++ .../testoperator.clusterserviceversion.yaml | 2 +- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/test/experimental-e2e/experimental_e2e_test.go b/test/experimental-e2e/experimental_e2e_test.go index 39c16e97f..eba429b91 100644 --- a/test/experimental-e2e/experimental_e2e_test.go +++ b/test/experimental-e2e/experimental_e2e_test.go @@ -248,6 +248,147 @@ func TestWebhookSupport(t *testing.T) { }, res.Object["spec"]) } +func TestClusterExtensionConfigSupport(t *testing.T) { + t.Log("Test support for cluster extension config") + defer utils.CollectTestArtifacts(t, artifactName, c, cfg) + + t.Log("By creating install namespace, watch namespace and necessary rbac resources") + namespace := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-operator", + }, + } + require.NoError(t, c.Create(t.Context(), &namespace)) + t.Cleanup(func() { + require.NoError(t, c.Delete(context.Background(), &namespace)) + }) + + watchNamespace := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-operator-watch", + }, + } + require.NoError(t, c.Create(t.Context(), &watchNamespace)) + t.Cleanup(func() { + require.NoError(t, c.Delete(context.Background(), &watchNamespace)) + }) + + serviceAccount := corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-operator-installer", + Namespace: namespace.GetName(), + }, + } + require.NoError(t, c.Create(t.Context(), &serviceAccount)) + t.Cleanup(func() { + require.NoError(t, c.Delete(context.Background(), &serviceAccount)) + }) + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-operator-installer", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: corev1.GroupName, + Name: serviceAccount.GetName(), + Namespace: serviceAccount.GetNamespace(), + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: "cluster-admin", + }, + } + require.NoError(t, c.Create(t.Context(), clusterRoleBinding)) + t.Cleanup(func() { + require.NoError(t, c.Delete(context.Background(), clusterRoleBinding)) + }) + + t.Log("By creating the test-operator ClusterCatalog") + extensionCatalog := &ocv1.ClusterCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-operator-catalog", + }, + Spec: ocv1.ClusterCatalogSpec{ + Source: ocv1.CatalogSource{ + Type: ocv1.SourceTypeImage, + Image: &ocv1.ImageSource{ + Ref: fmt.Sprintf("%s/e2e/test-catalog:v1", os.Getenv("CLUSTER_REGISTRY_HOST")), + PollIntervalMinutes: ptr.To(1), + }, + }, + }, + } + require.NoError(t, c.Create(t.Context(), extensionCatalog)) + t.Cleanup(func() { + require.NoError(t, c.Delete(context.Background(), extensionCatalog)) + }) + + t.Log("By waiting for the catalog to serve its metadata") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + require.NoError(ct, c.Get(context.Background(), types.NamespacedName{Name: extensionCatalog.GetName()}, extensionCatalog)) + cond := apimeta.FindStatusCondition(extensionCatalog.Status.Conditions, ocv1.TypeServing) + require.NotNil(ct, cond) + require.Equal(ct, metav1.ConditionTrue, cond.Status) + require.Equal(ct, ocv1.ReasonAvailable, cond.Reason) + }, pollDuration, pollInterval) + + t.Log("By installing the test-operator ClusterExtension configured in SingleNamespace mode") + clusterExtension := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-operator-extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{ + PackageName: "test", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"olm.operatorframework.io/metadata.name": extensionCatalog.Name}, + }, + }, + }, + Namespace: namespace.GetName(), + ServiceAccount: ocv1.ServiceAccountReference{ + Name: serviceAccount.GetName(), + }, + Config: &ocv1.ClusterExtensionConfig{ + ConfigType: ocv1.ClusterExtensionConfigTypeInline, + Inline: &apiextensionsv1.JSON{ + Raw: []byte(fmt.Sprintf(`{"watchNamespace": "%s"}`, watchNamespace.GetName())), + }, + }, + }, + } + require.NoError(t, c.Create(t.Context(), clusterExtension)) + t.Cleanup(func() { + require.NoError(t, c.Delete(context.Background(), clusterExtension)) + }) + + t.Log("By waiting for test-operator extension to be installed successfully") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + require.NoError(ct, c.Get(t.Context(), types.NamespacedName{Name: clusterExtension.Name}, clusterExtension)) + cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled) + require.NotNil(ct, cond) + require.Equal(ct, metav1.ConditionTrue, cond.Status) + require.Equal(ct, ocv1.ReasonSucceeded, cond.Reason) + require.Contains(ct, cond.Message, "Installed bundle") + require.NotNil(ct, clusterExtension.Status.Install) + require.NotEmpty(ct, clusterExtension.Status.Install.Bundle) + }, pollDuration, pollInterval) + + t.Log("By ensuring the test-operator deployment is correctly configured to watch the watch namespace") + require.EventuallyWithT(t, func(ct *assert.CollectT) { + deployment := &appsv1.Deployment{} + require.NoError(ct, c.Get(t.Context(), types.NamespacedName{Namespace: namespace.GetName(), Name: "test-operator"}, deployment)) + require.NotNil(ct, deployment.Spec.Template.GetAnnotations()) + require.Equal(ct, watchNamespace.GetName(), deployment.Spec.Template.GetAnnotations()["olm.targetNamespaces"]) + }, pollDuration, pollInterval) +} + func getWebhookOperatorResource(name string, namespace string, valid bool) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]interface{}{ diff --git a/testdata/images/bundles/test-operator/v1.0.0/manifests/testoperator.clusterserviceversion.yaml b/testdata/images/bundles/test-operator/v1.0.0/manifests/testoperator.clusterserviceversion.yaml index 3520f53db..54924492b 100644 --- a/testdata/images/bundles/test-operator/v1.0.0/manifests/testoperator.clusterserviceversion.yaml +++ b/testdata/images/bundles/test-operator/v1.0.0/manifests/testoperator.clusterserviceversion.yaml @@ -130,7 +130,7 @@ spec: installModes: - supported: false type: OwnNamespace - - supported: false + - supported: true type: SingleNamespace - supported: false type: MultiNamespace