diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go
index 6f7912ae9b..143b637b6d 100644
--- a/api/v1/clusterextension_types.go
+++ b/api/v1/clusterextension_types.go
@@ -49,21 +49,32 @@ const (
// ClusterExtensionSpec defines the desired state of ClusterExtension
type ClusterExtensionSpec struct {
- // namespace specifies a Kubernetes namespace.
- // It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- // Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- // This namespace must exist.
+ // namespace references an existing namespace where namespace-scoped resources
+ // for the extension are applied. The namespace must already exist on the cluster.
//
- // The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ //
+ // namespace is required.
+ //
+ //
+ // namespace is optional. When omitted, operator-controller resolves and creates a
+ // managed namespace from bundle metadata. The mode (set vs omitted) is locked at
+ // creation time and cannot be changed. Omitting namespace requires the experimental
+ // feature set (BoxcutterRuntime).
+ //
+ //
+ // The namespace field follows the DNS label standard as defined in [RFC 1123].
// It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
// and be no longer than 63 characters.
//
// [RFC 1123]: https://tools.ietf.org/html/rfc1123
//
+ //
+ //
+ //
// +kubebuilder:validation:MaxLength:=63
- // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="namespace is immutable"
- // +kubebuilder:validation:XValidation:rule="self.matches(\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\")",message="namespace must be a valid DNS1123 label"
- // +required
+ // +kubebuilder:validation:XValidation:rule="self == '' || self.matches(\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\")",message="namespace must be a valid DNS1123 label"
+ // +kubebuilder:validation:XValidation:rule="oldSelf == '' || self == oldSelf",message="namespace is immutable once set"
+ // +optional
Namespace string `json:"namespace"`
// serviceAccount is a deprecated field and is completely ignored.
diff --git a/applyconfigurations/api/v1/clusterextensionspec.go b/applyconfigurations/api/v1/clusterextensionspec.go
index 47d810a74a..fd61819bbb 100644
--- a/applyconfigurations/api/v1/clusterextensionspec.go
+++ b/applyconfigurations/api/v1/clusterextensionspec.go
@@ -22,15 +22,26 @@ package v1
//
// ClusterExtensionSpec defines the desired state of ClusterExtension
type ClusterExtensionSpecApplyConfiguration struct {
- // namespace specifies a Kubernetes namespace.
- // It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- // Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- // This namespace must exist.
+ // namespace references an existing namespace where namespace-scoped resources
+ // for the extension are applied. The namespace must already exist on the cluster.
//
- // The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ //
+ // namespace is required.
+ //
+ //
+ // namespace is optional. When omitted, operator-controller resolves and creates a
+ // managed namespace from bundle metadata. The mode (set vs omitted) is locked at
+ // creation time and cannot be changed. Omitting namespace requires the experimental
+ // feature set (BoxcutterRuntime).
+ //
+ //
+ // The namespace field follows the DNS label standard as defined in [RFC 1123].
// It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
// and be no longer than 63 characters.
//
+ //
+ //
+ //
// [RFC 1123]: https://tools.ietf.org/html/rfc1123
Namespace *string `json:"namespace,omitempty"`
// serviceAccount is a deprecated field and is completely ignored.
diff --git a/applyconfigurations/api/v1/clusterextensionstatus.go b/applyconfigurations/api/v1/clusterextensionstatus.go
index d11ad931dd..d05f981ca3 100644
--- a/applyconfigurations/api/v1/clusterextensionstatus.go
+++ b/applyconfigurations/api/v1/clusterextensionstatus.go
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
-// Code generated by controller-gen-v0.20. DO NOT EDIT.
+// Code generated by controller-gen-v0.21. DO NOT EDIT.
package v1
diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go
index 2fcea83ef0..4decf0f6bf 100644
--- a/cmd/operator-controller/main.go
+++ b/cmd/operator-controller/main.go
@@ -507,6 +507,7 @@ func run() error {
IsWebhookSupportEnabled: certProvider != nil,
IsSingleOwnNamespaceEnabled: features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport),
IsDeploymentConfigEnabled: features.OperatorControllerFeatureGate.Enabled(features.DeploymentConfig),
+ IsBoxcutterRuntimeEnabled: features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime),
}
var cerCfg reconcilerConfigurator
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {
@@ -659,6 +660,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
controllers.RetrieveRevisionStates(revisionStatesGetter),
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
controllers.UnpackBundle(c.imagePuller, c.imageCache),
+ controllers.ValidateInstallNamespace(coreClient),
controllers.ApplyBundleWithBoxcutter(appl.Apply),
}
@@ -746,6 +748,7 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster
controllers.RetrieveRevisionStates(revisionStatesGetter),
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
controllers.UnpackBundle(c.imagePuller, c.imageCache),
+ controllers.ValidateInstallNamespace(coreClient),
controllers.ApplyBundle(appl),
}
diff --git a/docs/draft/concepts/managed-namespaces.md b/docs/draft/concepts/managed-namespaces.md
new file mode 100644
index 0000000000..d2bdc1f0bd
--- /dev/null
+++ b/docs/draft/concepts/managed-namespaces.md
@@ -0,0 +1,53 @@
+# Managed Namespaces
+
+## What is a managed namespace?
+
+> **Note:** Managed namespaces (omitting `spec.namespace`) are available only in the
+> experimental feature set, which enables the `BoxcutterRuntime` feature gate. In the
+> standard feature set, `spec.namespace` is required.
+
+For registry+v1 bundles, when you create a ClusterExtension without specifying `spec.namespace`, operator-controller automatically creates and manages a namespace for the operator. The namespace name comes from the bundle's metadata or defaults to `-system`.
+
+When you specify `spec.namespace`, the namespace must already exist on the cluster and operator-controller installs into it without managing its lifecycle.
+
+The mode is locked at creation time: you cannot switch between managed and user-provided after the ClusterExtension is created.
+
+Managed mode requires the `BoxcutterRuntime` feature gate. Without it, omitting `spec.namespace` results in a terminal error, so you must set `spec.namespace` to an existing namespace instead.
+
+> **Note:** The behavior described in this document applies to the registry+v1 bundle format. Other bundle formats are likely to handle namespace management differently — for example, by including namespace objects directly in their manifests. This points toward namespace configuration being bundle-format-specific rather than a top-level ClusterExtension concern.
+
+## Namespace resolution
+
+For registry+v1 bundles in managed mode, the namespace name is resolved from CSV annotations in this order:
+
+1. `operatorframework.io/suggested-namespace-template`: the `metadata.name` field from the JSON template
+2. `operatorframework.io/suggested-namespace`: a plain string with the preferred name
+3. `-system`: convention fallback
+
+## What belongs in a managed namespace
+
+- The operator's own workloads (deployments, services, configmaps)
+- The operator's RBAC resources (service accounts, roles, role bindings)
+- CRDs and webhooks installed by the operator
+
+## What does NOT belong in a managed namespace
+
+- User application workloads
+- Shared services used by multiple operators
+- Persistent data that should survive operator uninstallation
+
+## Deletion behavior
+
+Deleting a ClusterExtension with a managed namespace **deletes the entire namespace and everything in it.** If you have created resources in the managed namespace that are not part of the operator, they will be lost.
+
+If you need the namespace to persist beyond the operator's lifecycle, use `spec.namespace` to point at an existing namespace you manage yourself.
+
+## PSA labels
+
+If the bundle declares PSA requirements via `operatorframework.io/suggested-namespace-template`, those labels are applied to the managed namespace automatically. This ensures the namespace has the correct Pod Security Admission level for the operator's workloads without manual configuration.
+
+## Drift protection
+
+Managed namespaces are reconciled by the ClusterObjectSet controller. If someone manually modifies or removes labels that the controller owns (e.g., PSA labels from the template), they are automatically restored.
+
+Labels or annotations added by other actors that don't conflict with controller-owned fields are preserved.
diff --git a/docs/howto/namespace-configuration-for-authors.md b/docs/howto/namespace-configuration-for-authors.md
new file mode 100644
index 0000000000..ff71370bb9
--- /dev/null
+++ b/docs/howto/namespace-configuration-for-authors.md
@@ -0,0 +1,63 @@
+# Namespace Configuration for Bundle Authors
+
+> **Note:** Managed namespaces (omitting `spec.namespace`) are available only in the
+> experimental feature set, which enables the `BoxcutterRuntime` feature gate. In the
+> standard feature set, `spec.namespace` is required, and the annotations described
+> below are not consulted.
+
+Bundle authors can specify their preferred namespace configuration through CSV annotations. These annotations are used by operator-controller when the cluster admin does not provide an explicit `spec.namespace`, which requires the experimental feature set.
+
+## Annotations
+
+### `operatorframework.io/suggested-namespace-template`
+
+Full namespace template with metadata. Use this when your operator needs specific labels or annotations on its namespace (e.g., PSA labels).
+
+```yaml
+apiVersion: operators.coreos.com/v1alpha1
+kind: ClusterServiceVersion
+metadata:
+ name: my-operator.v1.0.0
+ annotations:
+ operatorframework.io/suggested-namespace-template: |
+ {
+ "apiVersion": "v1",
+ "kind": "Namespace",
+ "metadata": {
+ "name": "my-operator-system",
+ "labels": {
+ "pod-security.kubernetes.io/enforce": "privileged",
+ "pod-security.kubernetes.io/audit": "privileged",
+ "pod-security.kubernetes.io/warn": "privileged"
+ }
+ }
+ }
+```
+
+### `operatorframework.io/suggested-namespace`
+
+Simple namespace name without metadata. Use this when you want a specific name but don't need labels or annotations.
+
+```yaml
+annotations:
+ operatorframework.io/suggested-namespace: my-operator-system
+```
+
+### No annotation
+
+If neither annotation is present, operator-controller uses `-system` as the namespace name.
+
+## Priority
+
+If both annotations are present, `suggested-namespace-template` takes priority.
+
+## Guidelines
+
+- Always include PSA labels if your operator runs privileged containers.
+- Use a descriptive, unique namespace name that includes your package name to avoid collisions.
+- Do not assume the namespace name will be exactly what you suggest as cluster admins can override it by setting `spec.namespace`.
+- The namespace name from the template is used only when `spec.namespace` is omitted. When set, the admin's choice takes precedence and no namespace object is created.
+
+## Consistency across bundle formats
+
+The `operatorframework.io/suggested-namespace-template` and `operatorframework.io/suggested-namespace` annotations are the canonical way to declare namespace preferences. Future bundle formats should use the same annotation keys to avoid divergence across the ecosystem.
diff --git a/hack/tools/crd-generator/main.go b/hack/tools/crd-generator/main.go
index edc254494e..61ff38a941 100644
--- a/hack/tools/crd-generator/main.go
+++ b/hack/tools/crd-generator/main.go
@@ -260,8 +260,8 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
numValid++
jsonProps.XValidations = append(jsonProps.XValidations, apiextensionsv1.ValidationRule{
- Message: celMatch[1],
- Rule: celMatch[2],
+ Rule: celMatch[1],
+ Message: celMatch[2],
})
}
optReqRe := regexp.MustCompile(validationPrefix + "(Optional|Required)>")
diff --git a/hack/tools/crd-generator/main_test.go b/hack/tools/crd-generator/main_test.go
index aebef0b336..869d379ba0 100644
--- a/hack/tools/crd-generator/main_test.go
+++ b/hack/tools/crd-generator/main_test.go
@@ -13,6 +13,49 @@ import (
const controllerToolsVersion = "v0.21.0"
+// TestOpconTweaksXValidation verifies that a channel-specific XValidation opcon
+// tag maps the captured rule and message into the correct ValidationRule fields.
+func TestOpconTweaksXValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ channel string
+ description string
+ expectRule string
+ expectMessage string
+ expectRuleCount int
+ }{
+ {
+ name: "experimental xvalidation applied in experimental channel",
+ channel: ExperimentalChannel,
+ description: `Field description.` + "\n" + ``,
+ expectRule: "oldSelf != '' || self == ''",
+ expectMessage: "mode is locked at creation time",
+ expectRuleCount: 1,
+ },
+ {
+ name: "experimental xvalidation ignored in standard channel",
+ channel: StandardChannel,
+ description: `Field description.` + "\n" + ``,
+ expectRuleCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ jsonProps := apiextensionsv1.JSONSchemaProps{
+ Description: tt.description,
+ Type: "string",
+ }
+ out, _ := opconTweaks(tt.channel, "namespace", jsonProps)
+ require.Len(t, out.XValidations, tt.expectRuleCount)
+ if tt.expectRuleCount > 0 {
+ require.Equal(t, tt.expectRule, out.XValidations[0].Rule)
+ require.Equal(t, tt.expectMessage, out.XValidations[0].Message)
+ }
+ })
+ }
+}
+
func TestRunGenerator(t *testing.T) {
here, err := os.Getwd()
require.NoError(t, err)
diff --git a/hack/tools/crd-generator/testdata/output/experimental/olm.operatorframework.io_clusterextensions.yaml b/hack/tools/crd-generator/testdata/output/experimental/olm.operatorframework.io_clusterextensions.yaml
index 73505ecd50..59d93f5c66 100644
--- a/hack/tools/crd-generator/testdata/output/experimental/olm.operatorframework.io_clusterextensions.yaml
+++ b/hack/tools/crd-generator/testdata/output/experimental/olm.operatorframework.io_clusterextensions.yaml
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
- controller-gen.kubebuilder.io/version: v0.20.1
+ controller-gen.kubebuilder.io/version: v0.21.0
olm.operatorframework.io/generator: experimental
name: clusterextensions.olm.operatorframework.io
spec:
@@ -128,8 +128,8 @@ spec:
x-kubernetes-validations:
- message: namespace must be a valid DNS1123 label
rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
- - message: self == oldSelf
- rule: namespace really is immutable
+ - message: namespace really is immutable
+ rule: self == oldSelf
serviceAccount:
description: |-
serviceAccount is a reference to a ServiceAccount used to perform all interactions
diff --git a/hack/tools/crd-generator/testdata/output/standard/olm.operatorframework.io_clusterextensions.yaml b/hack/tools/crd-generator/testdata/output/standard/olm.operatorframework.io_clusterextensions.yaml
index 90c33c902a..e1ccc6e67c 100644
--- a/hack/tools/crd-generator/testdata/output/standard/olm.operatorframework.io_clusterextensions.yaml
+++ b/hack/tools/crd-generator/testdata/output/standard/olm.operatorframework.io_clusterextensions.yaml
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
- controller-gen.kubebuilder.io/version: v0.20.1
+ controller-gen.kubebuilder.io/version: v0.21.0
olm.operatorframework.io/generator: standard
name: clusterextensions.olm.operatorframework.io
spec:
@@ -128,8 +128,8 @@ spec:
x-kubernetes-validations:
- message: namespace must be a valid DNS1123 label
rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
- - message: self == oldSelf
- rule: namespace is immutable
+ - message: namespace is immutable
+ rule: self == oldSelf
serviceAccount:
description: |-
serviceAccount is a reference to a ServiceAccount used to perform all interactions
diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
index 3082a69946..bec46ec798 100644
--- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
+++ b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
@@ -147,12 +147,15 @@ spec:
rule: has(self.preflight)
namespace:
description: |-
- namespace specifies a Kubernetes namespace.
- It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- This namespace must exist.
+ namespace references an existing namespace where namespace-scoped resources
+ for the extension are applied. The namespace must already exist on the cluster.
- The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ namespace is optional. When omitted, operator-controller resolves and creates a
+ managed namespace from bundle metadata. The mode (set vs omitted) is locked at
+ creation time and cannot be changed. Omitting namespace requires the experimental
+ feature set (BoxcutterRuntime).
+
+ The namespace field follows the DNS label standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
and be no longer than 63 characters.
@@ -160,10 +163,13 @@ spec:
maxLength: 63
type: string
x-kubernetes-validations:
- - message: namespace is immutable
- rule: self == oldSelf
- message: namespace must be a valid DNS1123 label
- rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ rule: self == '' || self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ - message: namespace is immutable once set
+ rule: oldSelf == '' || self == oldSelf
+ - message: namespace cannot be set after creation; mode is locked
+ at creation time
+ rule: oldSelf != '' || self == ''
progressDeadlineMinutes:
description: |-
progressDeadlineMinutes is an optional field that defines the maximum period
@@ -493,7 +499,6 @@ spec:
rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ?
has(self.catalog) : !has(self.catalog)'
required:
- - namespace
- source
type: object
status:
diff --git a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
index 954dea621e..d42c738f64 100644
--- a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
+++ b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
@@ -109,12 +109,12 @@ spec:
rule: has(self.preflight)
namespace:
description: |-
- namespace specifies a Kubernetes namespace.
- It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- This namespace must exist.
+ namespace references an existing namespace where namespace-scoped resources
+ for the extension are applied. The namespace must already exist on the cluster.
- The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ namespace is required.
+
+ The namespace field follows the DNS label standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
and be no longer than 63 characters.
@@ -122,10 +122,10 @@ spec:
maxLength: 63
type: string
x-kubernetes-validations:
- - message: namespace is immutable
- rule: self == oldSelf
- message: namespace must be a valid DNS1123 label
- rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ rule: self == '' || self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ - message: namespace is immutable once set
+ rule: oldSelf == '' || self == oldSelf
serviceAccount:
description: |-
serviceAccount is a deprecated field and is completely ignored.
@@ -445,8 +445,8 @@ spec:
rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ?
has(self.catalog) : !has(self.catalog)'
required:
- - namespace
- source
+ - namespace
type: object
status:
description: status is an optional field that defines the observed state
diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go
index a52fa21c7e..42f96275c3 100644
--- a/internal/operator-controller/applier/boxcutter.go
+++ b/internal/operator-controller/applier/boxcutter.go
@@ -114,7 +114,8 @@ func (r *SimpleRevisionGenerator) GenerateRevision(
bundleFS fs.FS, ext *ocv1.ClusterExtension,
objectLabels, revisionAnnotations map[string]string,
) (*ocv1ac.ClusterObjectSetApplyConfiguration, error) {
- // extract plain manifests
+ // extract plain manifests; the renderer decides whether a system-managed
+ // Namespace object is part of the returned object set.
plain, err := r.ManifestProvider.Get(bundleFS, ext)
if err != nil {
return nil, err
@@ -125,7 +126,7 @@ func (r *SimpleRevisionGenerator) GenerateRevision(
}
// add bundle properties of interest to revision annotations
- bundleAnnotations, err := getBundleAnnotations(bundleFS)
+ bundleAnnotations, err := GetBundleAnnotations(bundleFS)
if err != nil {
return nil, fmt.Errorf("error getting bundle annotations: %w", err)
}
@@ -178,6 +179,7 @@ func (r *SimpleRevisionGenerator) GenerateRevision(
objs = append(objs, *ocv1ac.ClusterObjectSetObject().
WithObject(unstr))
}
+
rev := r.buildClusterObjectSet(objs, ext, revisionAnnotations)
rev.Spec.WithCollisionProtection(ocv1.CollisionProtectionPrevent)
return rev, nil
@@ -273,6 +275,11 @@ type boxcutterStorageMigratorClient interface {
// Migrate creates a ClusterObjectSet from an existing Helm release if no revisions exist yet.
// The migration is idempotent and skipped if revisions already exist or no Helm release is found.
func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) error {
+ // Managed namespace mode (spec.namespace empty) means this is a new-style extension
+ // that never had a Helm release, so there's nothing to migrate.
+ if ext.Spec.Namespace == "" {
+ return nil
+ }
existingRevisionList := ocv1.ClusterObjectSetList{}
if err := m.Client.List(ctx, &existingRevisionList, client.MatchingLabels{
labels.OwnerNameKey: ext.Name,
diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go
index 25963c9a01..35f7a73430 100644
--- a/internal/operator-controller/applier/boxcutter_test.go
+++ b/internal/operator-controller/applier/boxcutter_test.go
@@ -92,66 +92,30 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T)
rev, err := g.GenerateRevisionFromHelmRelease(t.Context(), helmRelease, ext, objectLabels)
require.NoError(t, err)
- expected := ocv1ac.ClusterObjectSet("test-123-1").
- WithAnnotations(map[string]string{
- "olm.operatorframework.io/bundle-name": "my-bundle",
- "olm.operatorframework.io/bundle-reference": "bundle-ref",
- "olm.operatorframework.io/bundle-version": "1.2.0",
- "olm.operatorframework.io/package-name": "my-package",
- }).
- WithLabels(map[string]string{
- labels.OwnerKindKey: ocv1.ClusterExtensionKind,
- labels.OwnerNameKey: "test-123",
- }).
- WithSpec(ocv1ac.ClusterObjectSetSpec().
- WithLifecycleState(ocv1.ClusterObjectSetLifecycleStateActive).
- WithCollisionProtection(ocv1.CollisionProtectionNone).
- WithRevision(1).
- WithPhases(
- ocv1ac.ClusterObjectSetPhase().
- WithName("configuration").
- WithObjects(
- ocv1ac.ClusterObjectSetObject().
- WithObject(unstructured.Unstructured{
- Object: map[string]interface{}{
- "apiVersion": "v1",
- "kind": "ConfigMap",
- "metadata": map[string]interface{}{
- "labels": map[string]interface{}{
- "my-label": "my-value",
- },
- "annotations": map[string]interface{}{
- "olm.operatorframework.io/bundle-version": "1.2.0",
- "olm.operatorframework.io/package-name": "my-package",
- },
- },
- },
- }),
- ocv1ac.ClusterObjectSetObject().
- WithObject(unstructured.Unstructured{
- Object: map[string]interface{}{
- "apiVersion": "v1",
- "kind": "Secret",
- "metadata": map[string]interface{}{
- "labels": map[string]interface{}{
- "my-label": "my-value",
- },
- "annotations": map[string]interface{}{
- "olm.operatorframework.io/bundle-version": "1.2.0",
- "olm.operatorframework.io/package-name": "my-package",
- },
- },
- },
- }),
- )),
- )
- assert.Equal(t, expected.Name, rev.Name)
- assert.Equal(t, expected.Labels, rev.Labels)
- assert.Equal(t, expected.Annotations, rev.Annotations)
- assert.Equal(t, expected.Spec.LifecycleState, rev.Spec.LifecycleState)
- assert.Equal(t, expected.Spec.CollisionProtection, rev.Spec.CollisionProtection)
- assert.Equal(t, expected.Spec.Revision, rev.Spec.Revision)
- assert.Equal(t, expected.Spec.Phases, rev.Spec.Phases)
+ assert.Equal(t, "test-123-1", *rev.Name)
+ assert.Equal(t, map[string]string{
+ labels.OwnerKindKey: ocv1.ClusterExtensionKind,
+ labels.OwnerNameKey: "test-123",
+ }, rev.Labels)
+ assert.Equal(t, map[string]string{
+ "olm.operatorframework.io/bundle-name": "my-bundle",
+ "olm.operatorframework.io/bundle-reference": "bundle-ref",
+ "olm.operatorframework.io/bundle-version": "1.2.0",
+ "olm.operatorframework.io/package-name": "my-package",
+ }, rev.Annotations)
+ assert.Equal(t, ptr.To(ocv1.ClusterObjectSetLifecycleStateActive), rev.Spec.LifecycleState)
+ assert.Equal(t, ptr.To(ocv1.CollisionProtectionNone), rev.Spec.CollisionProtection)
+ assert.Equal(t, ptr.To(int64(1)), rev.Spec.Revision)
+
+ // The Helm-release migration path never injects a namespace (the release's
+ // namespace already exists), so only the configuration phase is present.
+ require.Len(t, rev.Spec.Phases, 1)
+
+ configPhase := rev.Spec.Phases[0]
+ assert.Equal(t, "configuration", *configPhase.Name)
+ require.Len(t, configPhase.Objects, 2)
+ assert.Equal(t, "ConfigMap", configPhase.Objects[0].Object.GetKind())
+ assert.Equal(t, "Secret", configPhase.Objects[1].Object.GetKind())
}
func Test_SimpleRevisionGenerator_GenerateRevision(t *testing.T) {
@@ -414,10 +378,17 @@ func Test_SimpleRevisionGenerator_AppliesObjectLabelsAndRevisionAnnotations(t *t
t.Log("by checking the rendered objects contain the given object labels")
for _, phase := range rev.Spec.Phases {
for _, revObj := range phase.Objects {
- require.Equal(t, map[string]string{
- "app": "test-obj",
- "some": "value",
- }, revObj.Object.GetLabels())
+ // Namespace objects only have objectLabels, not bundle object labels
+ if revObj.Object.GetKind() == "Namespace" {
+ require.Equal(t, map[string]string{
+ "some": "value",
+ }, revObj.Object.GetLabels())
+ } else {
+ require.Equal(t, map[string]string{
+ "app": "test-obj",
+ "some": "value",
+ }, revObj.Object.GetLabels())
+ }
}
}
t.Log("by checking the generated revision contain the given annotations")
@@ -1141,7 +1112,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
ctrl := gomock.NewController(t)
brb := newStorageMigratorGenerator(t)
@@ -1214,7 +1185,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
// GenerateRevisionFromHelmRelease should not be called when revisions already exist
ctrl := gomock.NewController(t)
@@ -1269,7 +1240,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
ctrl := gomock.NewController(t)
brb := mockapplier.NewMockClusterObjectSetGenerator(ctrl)
@@ -1342,7 +1313,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
ctrl := gomock.NewController(t)
brb := mockapplier.NewMockClusterObjectSetGenerator(ctrl)
@@ -1425,7 +1396,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
ctrl := gomock.NewController(t)
brb := mockapplier.NewMockClusterObjectSetGenerator(ctrl)
@@ -1482,7 +1453,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
expectedRelease := &release.Release{
Name: "test123",
@@ -1579,7 +1550,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
ctrl := gomock.NewController(t)
// GenerateRevisionFromHelmRelease should NOT be called when no deployed release exists
@@ -1626,7 +1597,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, ocv1.AddToScheme(testScheme))
ext := &ocv1.ClusterExtension{
- ObjectMeta: metav1.ObjectMeta{Name: "test123"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test123"}, Spec: ocv1.ClusterExtensionSpec{Namespace: "test-namespace"},
}
ctrl := gomock.NewController(t)
brb := mockapplier.NewMockClusterObjectSetGenerator(ctrl)
@@ -1651,3 +1622,167 @@ func TestBoxcutterStorageMigrator(t *testing.T) {
require.NoError(t, err)
})
}
+
+func Test_SimpleRevisionGenerator_GenerateRevision_NamespacePhaseCollisionProtection(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ r := mockapplier.NewMockManifestProvider(ctrl)
+ // The renderer (mocked here) is responsible for emitting the Namespace object;
+ // this test verifies the generator organizes it into the namespaces phase.
+ r.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]client.Object{
+ &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-namespace",
+ },
+ },
+ &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-service",
+ },
+ },
+ }, nil).AnyTimes()
+
+ b := applier.SimpleRevisionGenerator{
+ Scheme: k8scheme.Scheme,
+ ManifestProvider: r,
+ }
+
+ ext := &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-extension",
+ },
+ Spec: ocv1.ClusterExtensionSpec{
+ Namespace: "test-namespace",
+ ServiceAccount: ocv1.ServiceAccountReference{ //nolint:staticcheck // deprecated field used in test
+ Name: "test-sa",
+ },
+ },
+ }
+
+ rev, err := b.GenerateRevision(t.Context(), dummyBundle, ext, map[string]string{}, map[string]string{})
+ require.NoError(t, err)
+ require.NotNil(t, rev)
+
+ t.Log("by checking the spec-level collision protection is set to Prevent")
+ require.Equal(t, ptr.To(ocv1.CollisionProtectionPrevent), rev.Spec.CollisionProtection)
+
+ // Find the namespaces phase
+ var namespacesPhase *ocv1ac.ClusterObjectSetPhaseApplyConfiguration
+ for i := range rev.Spec.Phases {
+ if *rev.Spec.Phases[i].Name == string(applier.PhaseNamespaces) {
+ namespacesPhase = &rev.Spec.Phases[i]
+ break
+ }
+ }
+
+ require.NotNil(t, namespacesPhase, "namespaces phase should exist")
+
+ t.Log("by checking the namespaces phase inherits Prevent collision protection from spec (no explicit override)")
+ require.Nil(t, namespacesPhase.CollisionProtection, "namespaces phase should inherit collision protection from spec")
+
+ // Verify all phases inherit from spec (no explicit collision protection)
+ for i := range rev.Spec.Phases {
+ t.Logf("by checking phase %s does not have explicit collision protection", *rev.Spec.Phases[i].Name)
+ require.Nil(t, rev.Spec.Phases[i].CollisionProtection, "all phases should inherit collision protection from spec")
+ }
+}
+
+func Test_GenerateRevision_NamespacePhaseIsFirst(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ r := mockapplier.NewMockManifestProvider(ctrl)
+ // The renderer (mocked here) emits the Namespace object; this test verifies the
+ // generator places the namespaces phase first for proper deletion ordering.
+ r.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]client.Object{
+ &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-service",
+ },
+ },
+ &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-namespace",
+ },
+ },
+ &appsv1.Deployment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-deployment",
+ Namespace: "test-ns",
+ },
+ },
+ }, nil).AnyTimes()
+
+ b := applier.SimpleRevisionGenerator{
+ Scheme: k8scheme.Scheme,
+ ManifestProvider: r,
+ }
+
+ ext := &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-extension",
+ },
+ Spec: ocv1.ClusterExtensionSpec{
+ Namespace: "test-namespace",
+ ServiceAccount: ocv1.ServiceAccountReference{ //nolint:staticcheck // deprecated field used in test
+ Name: "test-sa",
+ },
+ },
+ }
+
+ rev, err := b.GenerateRevision(t.Context(), dummyBundle, ext, map[string]string{}, map[string]string{})
+ require.NoError(t, err)
+
+ t.Log("by checking that phases are present")
+ require.NotEmpty(t, rev.Spec.Phases, "revision should have at least one phase")
+
+ t.Log("by checking that the first phase is the namespaces phase")
+ firstPhase := rev.Spec.Phases[0]
+ require.Equal(t, "namespaces", *firstPhase.Name, "first phase should be namespaces for proper deletion ordering")
+
+ t.Log("by checking that the namespaces phase contains exactly one namespace object")
+ require.Len(t, firstPhase.Objects, 1, "namespaces phase should contain exactly one object")
+
+ t.Log("by checking that the namespace object has the correct name")
+ nsObj := firstPhase.Objects[0].Object
+ require.Equal(t, "Namespace", nsObj.GetKind())
+ require.Equal(t, "test-namespace", nsObj.GetName(), "namespace name should match ext.Spec.Namespace")
+}
+
+func Test_GenerateRevision_COSHasOwnerLabels(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ r := mockapplier.NewMockManifestProvider(ctrl)
+ r.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]client.Object{
+ &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-configmap",
+ },
+ },
+ }, nil).AnyTimes()
+
+ b := applier.SimpleRevisionGenerator{
+ Scheme: k8scheme.Scheme,
+ ManifestProvider: r,
+ }
+
+ ext := &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-extension",
+ },
+ Spec: ocv1.ClusterExtensionSpec{
+ Namespace: "test-namespace",
+ ServiceAccount: ocv1.ServiceAccountReference{ //nolint:staticcheck // deprecated field used in test
+ Name: "test-sa",
+ },
+ },
+ }
+
+ rev, err := b.GenerateRevision(t.Context(), dummyBundle, ext, map[string]string{}, map[string]string{})
+ require.NoError(t, err)
+
+ t.Log("by checking that the COS has owner-kind label")
+ require.NotNil(t, rev.Labels, "COS should have labels")
+ require.Equal(t, ocv1.ClusterExtensionKind, rev.Labels[labels.OwnerKindKey],
+ "COS should have owner-kind label set to ClusterExtension")
+
+ t.Log("by checking that the COS has owner-name label")
+ require.Equal(t, "test-extension", rev.Labels[labels.OwnerNameKey],
+ "COS should have owner-name label matching the ClusterExtension name")
+}
diff --git a/internal/operator-controller/applier/provider.go b/internal/operator-controller/applier/provider.go
index e82d17ba46..c0cb9a4109 100644
--- a/internal/operator-controller/applier/provider.go
+++ b/internal/operator-controller/applier/provider.go
@@ -22,7 +22,8 @@ import (
// ManifestProvider returns the manifests that should be applied by OLM given a bundle and its associated ClusterExtension
type ManifestProvider interface {
- // Get returns a set of resource manifests in bundle that take into account the configuration in ext
+ // Get returns a set of resource manifests in bundle that take into account the
+ // configuration in ext.
Get(bundle fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error)
}
@@ -34,6 +35,7 @@ type RegistryV1ManifestProvider struct {
IsWebhookSupportEnabled bool
IsSingleOwnNamespaceEnabled bool
IsDeploymentConfigEnabled bool
+ IsBoxcutterRuntimeEnabled bool
}
func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) {
@@ -67,10 +69,21 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens
return nil, fmt.Errorf("unsupported bundle: bundle must support at least one of [AllNamespaces SingleNamespace OwnNamespace] install modes")
}
+ if ext.Spec.Namespace == "" && !r.IsBoxcutterRuntimeEnabled {
+ return nil, errorutil.NewTerminalError(ocv1.ReasonInvalidConfiguration, fmt.Errorf("spec.namespace is required unless the BoxcutterRuntime feature gate is enabled"))
+ }
+
opts := []render.Option{
render.WithCertificateProvider(r.CertificateProvider),
}
+ // When the user set spec.namespace, the install namespace is caller-managed:
+ // render into it and do not emit a Namespace object. Otherwise the renderer
+ // resolves a system-managed namespace from the bundle and emits it.
+ if ext.Spec.Namespace != "" {
+ opts = append(opts, render.WithSelfManagedInstallNamespace(ext.Spec.Namespace))
+ }
+
// Always validate inline config when present so that disabled features produce
// a clear error rather than being silently ignored. When IsSingleOwnNamespaceEnabled
// is true we also call this with no config to validate required fields (e.g.
@@ -82,7 +95,8 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens
}
opts = append(opts, configOpts...)
}
- return r.BundleRenderer.Render(rv1, ext.Spec.Namespace, opts...)
+
+ return r.BundleRenderer.Render(rv1, opts...)
}
// extractBundleConfigOptions extracts and validates configuration options from a ClusterExtension.
@@ -187,7 +201,7 @@ func extensionConfigBytes(ext *ocv1.ClusterExtension) []byte {
return nil
}
-func getBundleAnnotations(bundleFS fs.FS) (map[string]string, error) {
+func GetBundleAnnotations(bundleFS fs.FS) (map[string]string, error) {
// The need to get the underlying bundle in order to extract its annotations
// will go away once we have a bundle interface that can surface the annotations independently of the
// underlying bundle format...
diff --git a/internal/operator-controller/applier/provider_test.go b/internal/operator-controller/applier/provider_test.go
index 6fb9760417..13f51b6cfc 100644
--- a/internal/operator-controller/applier/provider_test.go
+++ b/internal/operator-controller/applier/provider_test.go
@@ -2,6 +2,7 @@ package applier_test
import (
"errors"
+ "io/fs"
"testing"
"testing/fstest"
@@ -139,17 +140,7 @@ func Test_RegistryV1ManifestProvider_Integration(t *testing.T) {
provider := applier.RegistryV1ManifestProvider{
BundleRenderer: registryv1.Renderer,
}
- bundleFS := bundlefs.Builder().WithPackageName("test").
- WithCSV(bundlecsv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build()).
- WithBundleResource("service.yaml", &corev1.Service{
- TypeMeta: metav1.TypeMeta{
- APIVersion: corev1.SchemeGroupVersion.String(),
- Kind: "Service",
- },
- ObjectMeta: metav1.ObjectMeta{
- Name: "test-service",
- },
- }).Build()
+ bundleFS := newAllNamespacesBundleFS(t)
ext := &ocv1.ClusterExtension{
Spec: ocv1.ClusterExtensionSpec{
Namespace: "install-namespace",
@@ -174,6 +165,115 @@ func Test_RegistryV1ManifestProvider_Integration(t *testing.T) {
require.Equal(t, []client.Object{exp}, objs)
})
+
+ t.Run("emits a system-managed Namespace object when spec.namespace is empty", func(t *testing.T) {
+ provider := applier.RegistryV1ManifestProvider{
+ BundleRenderer: registryv1.Renderer,
+ IsBoxcutterRuntimeEnabled: true,
+ }
+ bundleFS := bundlefs.Builder().WithPackageName("test").
+ WithCSV(bundlecsv.Builder().
+ WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).
+ WithAnnotations(map[string]string{
+ render.AnnotationSuggestedNamespaceTemplate: `{"metadata":{"name":"managed-ns","labels":{"pod-security.kubernetes.io/enforce":"privileged"},"annotations":{"example.com/note":"hello"}}}`,
+ }).Build()).
+ WithBundleResource("service.yaml", &corev1.Service{
+ TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String(), Kind: "Service"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test-service"},
+ }).Build()
+ // No spec.namespace -> system-managed: the renderer resolves the name from
+ // bundle annotations and emits the Namespace object.
+ ext := &ocv1.ClusterExtension{}
+
+ objs, err := provider.Get(bundleFS, ext)
+ require.NoError(t, err)
+ require.NotEmpty(t, objs)
+
+ t.Log("by checking the Namespace object is emitted first")
+ ns := objs[0]
+ require.Equal(t, "Namespace", ns.GetObjectKind().GroupVersionKind().Kind)
+ require.Equal(t, "managed-ns", ns.GetName())
+
+ t.Log("by checking template labels and annotations are applied")
+ require.Equal(t, "privileged", ns.GetLabels()["pod-security.kubernetes.io/enforce"])
+ require.Equal(t, "hello", ns.GetAnnotations()["example.com/note"])
+ })
+
+ t.Run("does not emit a Namespace object when spec.namespace is set", func(t *testing.T) {
+ provider := applier.RegistryV1ManifestProvider{
+ BundleRenderer: registryv1.Renderer,
+ }
+ bundleFS := newAllNamespacesBundleFS(t)
+ ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Namespace: "install-namespace"}}
+
+ objs, err := provider.Get(bundleFS, ext)
+ require.NoError(t, err)
+ for _, o := range objs {
+ require.NotEqual(t, "Namespace", o.GetObjectKind().GroupVersionKind().Kind, "no Namespace should be emitted when Ensure is false")
+ }
+ })
+}
+
+func Test_RegistryV1ManifestProvider_BoxcutterRuntimeGate(t *testing.T) {
+ t.Run("rejects empty spec.namespace when the BoxcutterRuntime feature gate is disabled", func(t *testing.T) {
+ provider := applier.RegistryV1ManifestProvider{
+ BundleRenderer: registryv1.Renderer,
+ IsBoxcutterRuntimeEnabled: false,
+ }
+ bundleFS := newAllNamespacesBundleFS(t)
+ ext := &ocv1.ClusterExtension{}
+
+ _, err := provider.Get(bundleFS, ext)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "spec.namespace is required unless the BoxcutterRuntime feature gate is enabled")
+ require.ErrorIs(t, err, reconcile.TerminalError(nil), "namespace gate error should be terminal")
+ })
+
+ t.Run("allows empty spec.namespace and renders a managed Namespace when the BoxcutterRuntime feature gate is enabled", func(t *testing.T) {
+ provider := applier.RegistryV1ManifestProvider{
+ BundleRenderer: registryv1.Renderer,
+ IsBoxcutterRuntimeEnabled: true,
+ }
+ bundleFS := newAllNamespacesBundleFS(t)
+ ext := &ocv1.ClusterExtension{}
+
+ objs, err := provider.Get(bundleFS, ext)
+ require.NoError(t, err)
+ require.Contains(t, collectKinds(objs), "Namespace")
+ })
+
+ t.Run("ignores the BoxcutterRuntime feature gate when spec.namespace is set", func(t *testing.T) {
+ provider := applier.RegistryV1ManifestProvider{
+ BundleRenderer: registryv1.Renderer,
+ IsBoxcutterRuntimeEnabled: false,
+ }
+ bundleFS := newAllNamespacesBundleFS(t)
+ ext := &ocv1.ClusterExtension{Spec: ocv1.ClusterExtensionSpec{Namespace: "install-namespace"}}
+
+ objs, err := provider.Get(bundleFS, ext)
+ require.NoError(t, err)
+ require.NotContains(t, collectKinds(objs), "Namespace")
+ })
+}
+
+// newAllNamespacesBundleFS returns a minimal registry+v1 bundle FS that supports the
+// AllNamespaces install mode and includes a single Service resource named "test-service".
+func newAllNamespacesBundleFS(t *testing.T) fs.FS {
+ t.Helper()
+ return bundlefs.Builder().WithPackageName("test").
+ WithCSV(bundlecsv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build()).
+ WithBundleResource("service.yaml", &corev1.Service{
+ TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String(), Kind: "Service"},
+ ObjectMeta: metav1.ObjectMeta{Name: "test-service"},
+ }).Build()
+}
+
+func collectKinds(objs []client.Object) []string {
+ kinds := make([]string, 0, len(objs))
+ for _, o := range objs {
+ kinds = append(kinds, o.GetObjectKind().GroupVersionKind().Kind)
+ }
+ return kinds
}
func Test_RegistryV1ManifestProvider_APIServiceSupport(t *testing.T) {
diff --git a/internal/operator-controller/controllers/clusterextension_admission_test.go b/internal/operator-controller/controllers/clusterextension_admission_test.go
index 14cfea8fc9..4f2d9f325c 100644
--- a/internal/operator-controller/controllers/clusterextension_admission_test.go
+++ b/internal/operator-controller/controllers/clusterextension_admission_test.go
@@ -285,8 +285,8 @@ func TestClusterExtensionAdmissionInstallNamespace(t *testing.T) {
errMsg string
}{
{"just alphanumeric", "justalphanumberic1", ""},
- {"hyphen-separated", "hyphenated-name", ""},
- {"no install namespace", "", regexMismatchError},
+ {"hypen-separated", "hyphenated-name", ""},
+ {"no install namespace (managed mode)", "", ""},
{"dot-separated", "dotted.name", regexMismatchError},
{"longest valid install namespace", strings.Repeat("x", 63), ""},
{"too long install namespace name", strings.Repeat("x", 64), tooLongError},
@@ -325,9 +325,87 @@ func TestClusterExtensionAdmissionInstallNamespace(t *testing.T) {
}
}
-// TestClusterExtensionAdmissionServiceAccount validates the deprecated spec.serviceAccount field:
-// - CRD-level validation (format, length) still works
-// - ValidatingAdmissionPolicy emits a deprecation warning for valid non-empty values
+func TestClusterExtensionAdmissionNamespaceImmutability(t *testing.T) {
+ baseSpec := func(ns string) ocv1.ClusterExtensionSpec {
+ return ocv1.ClusterExtensionSpec{
+ Source: ocv1.SourceConfig{
+ SourceType: "Catalog",
+ Catalog: &ocv1.CatalogFilter{
+ PackageName: "package",
+ },
+ },
+ Namespace: ns,
+ ServiceAccount: ocv1.ServiceAccountReference{ //nolint:staticcheck // deprecated field used in test
+ Name: "default",
+ },
+ }
+ }
+
+ testCases := []struct {
+ name string
+ initialNS string
+ updatedNS string
+ expectErr bool
+ errContains string
+ }{
+ {
+ name: "set to same value - allowed",
+ initialNS: "my-ns",
+ updatedNS: "my-ns",
+ expectErr: false,
+ },
+ {
+ name: "set to different value - rejected",
+ initialNS: "my-ns",
+ updatedNS: "other-ns",
+ expectErr: true,
+ errContains: "namespace is immutable once set",
+ },
+ {
+ name: "empty to set - rejected",
+ initialNS: "",
+ updatedNS: "my-ns",
+ expectErr: true,
+ errContains: "namespace cannot be set after creation",
+ },
+ {
+ name: "empty to empty - allowed",
+ initialNS: "",
+ updatedNS: "",
+ expectErr: false,
+ },
+ {
+ name: "set to empty - rejected",
+ initialNS: "my-ns",
+ updatedNS: "",
+ expectErr: true,
+ errContains: "namespace is immutable once set",
+ },
+ }
+
+ t.Parallel()
+ for _, tc := range testCases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ cl := newClient(t)
+ ctx := context.Background()
+
+ ext := buildClusterExtension(baseSpec(tc.initialNS))
+ require.NoError(t, cl.Create(ctx, ext))
+
+ ext.Spec.Namespace = tc.updatedNS
+ err := cl.Update(ctx, ext)
+ if !tc.expectErr {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), tc.errContains)
+ }
+ })
+ }
+}
+
func TestClusterExtensionAdmissionServiceAccount(t *testing.T) {
tooLongError := "spec.serviceAccount.name: Too long: may not be more than 253"
regexMismatchError := "name must be a valid DNS1123 subdomain"
diff --git a/internal/operator-controller/controllers/clusterextension_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go
index 2637457752..ea5f636e07 100644
--- a/internal/operator-controller/controllers/clusterextension_controller_test.go
+++ b/internal/operator-controller/controllers/clusterextension_controller_test.go
@@ -15,11 +15,14 @@ import (
"go.uber.org/mock/gomock"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver"
+ corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/rand"
+ "k8s.io/client-go/kubernetes/fake"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -979,6 +982,92 @@ func TestValidateClusterExtension(t *testing.T) {
}
}
+func TestValidateInstallNamespace(t *testing.T) {
+ tests := []struct {
+ name string
+ specNamespace string
+ namespaceObjects []runtime.Object
+ expectError bool
+ errorMessageIncludes string
+ }{
+ {
+ name: "user-provided namespace exists",
+ specNamespace: "existing-ns",
+ namespaceObjects: []runtime.Object{
+ &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "existing-ns",
+ },
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "user-provided namespace not found",
+ specNamespace: "missing-ns",
+ namespaceObjects: nil,
+ expectError: true,
+ errorMessageIncludes: `namespace "missing-ns" not found`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+
+ objects := make([]runtime.Object, 0, len(tt.namespaceObjects))
+ objects = append(objects, tt.namespaceObjects...)
+ fakeClient := fake.NewClientset(objects...)
+
+ cl := newClient(t)
+ reconciler := &controllers.ClusterExtensionReconciler{
+ Client: cl,
+ ReconcileSteps: controllers.ReconcileSteps{
+ controllers.HandleFinalizers(crfinalizer.NewFinalizers()),
+ controllers.ValidateInstallNamespace(fakeClient.CoreV1()),
+ },
+ }
+
+ extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
+
+ clusterExtension := &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
+ Spec: ocv1.ClusterExtensionSpec{
+ Source: ocv1.SourceConfig{
+ SourceType: "Catalog",
+ Catalog: &ocv1.CatalogFilter{
+ PackageName: "test-package",
+ },
+ },
+ Namespace: tt.specNamespace,
+ ServiceAccount: ocv1.ServiceAccountReference{ //nolint:staticcheck // deprecated field used in test
+ Name: "test-sa",
+ },
+ },
+ }
+
+ require.NoError(t, cl.Create(ctx, clusterExtension))
+
+ res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
+ require.Equal(t, ctrl.Result{}, res)
+ if tt.expectError {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), tt.errorMessageIncludes)
+
+ require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
+ progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
+ require.NotNil(t, progressingCond)
+ require.Equal(t, metav1.ConditionFalse, progressingCond.Status)
+ require.Equal(t, ocv1.ReasonBlocked, progressingCond.Reason)
+ require.Contains(t, progressingCond.Message, tt.errorMessageIncludes)
+ } else {
+ require.NoError(t, err)
+ }
+ require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
+ })
+ }
+}
+
func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) {
// This test calls Reconcile twice: first with a successful applier,
// then with a failing applier. We use gomock.InOrder to sequence the calls.
diff --git a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go
index b07a5072f4..788983aa26 100644
--- a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go
+++ b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go
@@ -21,12 +21,15 @@ import (
"errors"
"fmt"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/finalizer"
"sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
ocv1 "github.com/operator-framework/operator-controller/api/v1"
"github.com/operator-framework/operator-controller/internal/operator-controller/bundleutil"
@@ -402,6 +405,33 @@ func UnpackBundle(i imageutil.Puller, cache imageutil.Cache) ReconcileStepFunc {
}
}
+// ValidateInstallNamespace validates a user-provided spec.namespace: it must
+// reference an existing namespace. When spec.namespace is omitted the install
+// namespace is system-managed and resolved+created by the bundle renderer, so
+// there is nothing to validate here — the emitted Namespace object is treated
+// like any other rendered object (conflicts are handled by collision protection).
+func ValidateInstallNamespace(nsClient corev1client.NamespacesGetter) ReconcileStepFunc {
+ return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
+ l := log.FromContext(ctx)
+
+ if ext.Spec.Namespace == "" {
+ return nil, nil
+ }
+
+ l.V(1).Info("validating user-provided namespace exists", "namespace", ext.Spec.Namespace)
+ _, err := nsClient.Namespaces().Get(ctx, ext.Spec.Namespace, metav1.GetOptions{})
+ if apierrors.IsNotFound(err) {
+ termErr := reconcile.TerminalError(fmt.Errorf("namespace %q not found; spec.namespace must reference an existing namespace", ext.Spec.Namespace))
+ setStatusProgressing(ext, termErr)
+ return nil, termErr
+ }
+ if err != nil {
+ return nil, fmt.Errorf("error checking namespace %q: %w", ext.Spec.Namespace, err)
+ }
+ return nil, nil
+ }
+}
+
func ApplyBundle(a Applier) ReconcileStepFunc {
return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
l := log.FromContext(ctx)
diff --git a/internal/operator-controller/controllers/clusterobjectset_controller.go b/internal/operator-controller/controllers/clusterobjectset_controller.go
index e42e3c6144..4c86dc2fe2 100644
--- a/internal/operator-controller/controllers/clusterobjectset_controller.go
+++ b/internal/operator-controller/controllers/clusterobjectset_controller.go
@@ -200,23 +200,25 @@ func (c *ClusterObjectSetReconciler) reconcile(ctx context.Context, cos *ocv1.Cl
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
- for i, pres := range rres.GetPhases() {
+ for _, pres := range rres.GetPhases() {
if verr := pres.GetValidationError(); verr != nil {
- l.Error(fmt.Errorf("%w", verr), "phase preflight validation failed, retrying after 10s", "phase", i)
- setRetryingConditions(l, cos, fmt.Sprintf("phase %d validation error: %s", i, verr), isDeadlineExceeded)
+ phaseName := pres.GetName()
+ l.Error(fmt.Errorf("%w", verr), "phase preflight validation failed, retrying after 10s", "phase", phaseName)
+ setRetryingConditions(l, cos, fmt.Sprintf("phase %q validation error: %s", phaseName, verr), isDeadlineExceeded)
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
var collidingObjs []string
for _, ores := range pres.GetObjects() {
if ores.Action() == machinery.ActionCollision {
- collidingObjs = append(collidingObjs, ores.String())
+ collidingObjs = append(collidingObjs, collisionMessage(ores))
}
}
if len(collidingObjs) > 0 {
- l.Error(fmt.Errorf("object collision detected"), "object collision, retrying after 10s", "phase", i, "collisions", collidingObjs)
- setRetryingConditions(l, cos, fmt.Sprintf("revision object collisions in phase %d\n%s", i, strings.Join(collidingObjs, "\n\n")), isDeadlineExceeded)
+ phaseName := pres.GetName()
+ l.Error(fmt.Errorf("object collision detected"), "object collision, retrying after 10s", "phase", phaseName, "collisions", collidingObjs)
+ setRetryingConditions(l, cos, fmt.Sprintf("revision object collisions in phase %q\n%s", phaseName, strings.Join(collidingObjs, "\n\n")), isDeadlineExceeded)
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
}
@@ -580,6 +582,30 @@ func (c *ClusterObjectSetReconciler) resolveObjectRef(ctx context.Context, ref o
return obj, nil
}
+func collisionMessage(ores machinery.ObjectResult) string {
+ obj := ores.Object()
+ gvk := obj.GetObjectKind().GroupVersionKind()
+ name := obj.GetName()
+
+ if collision, ok := ores.(machinery.ObjectResultCollision); ok {
+ if owner, hasOwner := collision.ConflictingOwner(); hasOwner {
+ ownerName := owner.Name
+ if gvk.Kind == "Namespace" {
+ return fmt.Sprintf("namespace %q is already managed by %s %q", name, owner.Kind, ownerName)
+ }
+ return fmt.Sprintf("%s %q is already managed by %s %q", gvk.Kind, name, owner.Kind, ownerName)
+ }
+ }
+
+ if gvk.Kind == "Namespace" {
+ return fmt.Sprintf("namespace %q is already managed by another controller", name)
+ }
+ if ns := obj.GetNamespace(); ns != "" {
+ return fmt.Sprintf("%s.%s %s/%s collision: %s", gvk.Kind, gvk.GroupVersion(), ns, name, ores.String())
+ }
+ return fmt.Sprintf("%s.%s %s collision: %s", gvk.Kind, gvk.GroupVersion(), name, ores.String())
+}
+
// EffectiveCollisionProtection resolves the collision protection value using
// the inheritance hierarchy: object > phase > spec > default ("Prevent").
func EffectiveCollisionProtection(cp ...ocv1.CollisionProtection) ocv1.CollisionProtection {
diff --git a/internal/operator-controller/rukpak/render/namespace.go b/internal/operator-controller/rukpak/render/namespace.go
new file mode 100644
index 0000000000..ac49f4961f
--- /dev/null
+++ b/internal/operator-controller/rukpak/render/namespace.go
@@ -0,0 +1,118 @@
+package render
+
+import (
+ "encoding/json"
+ "fmt"
+ "regexp"
+
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle"
+)
+
+const (
+ // AnnotationSuggestedNamespaceTemplate is a CSV annotation carrying a JSON
+ // Namespace template whose metadata seeds the system-managed namespace.
+ AnnotationSuggestedNamespaceTemplate = "operatorframework.io/suggested-namespace-template"
+ // AnnotationSuggestedNamespace is a CSV annotation carrying the preferred
+ // namespace name for the operator.
+ AnnotationSuggestedNamespace = "operatorframework.io/suggested-namespace"
+)
+
+var dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
+
+// resolveSystemManagedNamespace derives the name of the namespace OLM should
+// create and manage for a bundle, using the precedence:
+//
+// suggested-namespace-template name → suggested-namespace → -system
+//
+// It returns the resolved name and the parsed template (if any) so the caller can
+// seed labels/annotations (e.g. PSA) on the emitted Namespace object.
+func resolveSystemManagedNamespace(rv1 *bundle.RegistryV1) (string, *corev1.Namespace, error) {
+ csvAnnotations := rv1.CSV.GetAnnotations()
+
+ template, err := parseNamespaceTemplate(csvAnnotations)
+ if err != nil {
+ return "", nil, err
+ }
+
+ var name string
+ switch {
+ case template != nil && template.Name != "":
+ name = template.Name
+ case csvAnnotations[AnnotationSuggestedNamespace] != "":
+ name = csvAnnotations[AnnotationSuggestedNamespace]
+ default:
+ name = fmt.Sprintf("%s-system", rv1.PackageName)
+ }
+
+ if err := validateNamespaceName(name); err != nil {
+ return "", nil, err
+ }
+
+ return name, template, nil
+}
+
+func parseNamespaceTemplate(csvAnnotations map[string]string) (*corev1.Namespace, error) {
+ templateJSON, exists := csvAnnotations[AnnotationSuggestedNamespaceTemplate]
+ if !exists || templateJSON == "" {
+ return nil, nil
+ }
+
+ var ns corev1.Namespace
+ if err := json.Unmarshal([]byte(templateJSON), &ns); err != nil {
+ return nil, fmt.Errorf("failed to parse namespace template: %w", err)
+ }
+
+ return &ns, nil
+}
+
+func validateNamespaceName(name string) error {
+ if name == "" {
+ return fmt.Errorf("resolved namespace name is empty")
+ }
+ if len(name) > 63 {
+ return fmt.Errorf("resolved namespace name %q exceeds 63 characters", name)
+ }
+ if !dns1123LabelRegexp.MatchString(name) {
+ return fmt.Errorf("resolved namespace name %q is not a valid DNS1123 label", name)
+ }
+ return nil
+}
+
+// buildNamespaceObject returns the Namespace object to include in the rendered set,
+// seeding labels and annotations from the optional template. Empty spec/status are
+// stripped to avoid apply drift.
+func buildNamespaceObject(name string, template *corev1.Namespace) (client.Object, error) {
+ ns := corev1.Namespace{
+ TypeMeta: metav1.TypeMeta{
+ APIVersion: "v1",
+ Kind: "Namespace",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ },
+ }
+
+ if template != nil {
+ if len(template.Labels) > 0 {
+ ns.Labels = template.Labels
+ }
+ if len(template.Annotations) > 0 {
+ ns.Annotations = template.Annotations
+ }
+ }
+
+ unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&ns)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert namespace to unstructured: %w", err)
+ }
+ delete(unstructuredObj, "status")
+ delete(unstructuredObj, "spec")
+
+ return &unstructured.Unstructured{Object: unstructuredObj}, nil
+}
diff --git a/internal/operator-controller/rukpak/render/namespace_test.go b/internal/operator-controller/rukpak/render/namespace_test.go
new file mode 100644
index 0000000000..71314982b7
--- /dev/null
+++ b/internal/operator-controller/rukpak/render/namespace_test.go
@@ -0,0 +1,303 @@
+package render
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle"
+ "github.com/operator-framework/operator-controller/internal/testing/bundle/csv"
+)
+
+func rv1WithAnnotations(pkg string, annotations map[string]string) *bundle.RegistryV1 {
+ return &bundle.RegistryV1{
+ PackageName: pkg,
+ CSV: csv.Builder().WithName("test-csv").WithAnnotations(annotations).Build(),
+ }
+}
+
+func TestParseNamespaceTemplate(t *testing.T) {
+ tests := []struct {
+ name string
+ annotations map[string]string
+ expected *corev1.Namespace
+ expectError bool
+ }{
+ {
+ name: "nil annotations",
+ annotations: nil,
+ expected: nil,
+ },
+ {
+ name: "empty map",
+ annotations: map[string]string{},
+ expected: nil,
+ },
+ {
+ name: "annotation absent",
+ annotations: map[string]string{"some.other/annotation": "value"},
+ expected: nil,
+ },
+ {
+ name: "empty string value",
+ annotations: map[string]string{AnnotationSuggestedNamespaceTemplate: ""},
+ expected: nil,
+ },
+ {
+ name: "valid template with PSA labels",
+ annotations: map[string]string{
+ AnnotationSuggestedNamespaceTemplate: `{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"restricted"}}}`,
+ },
+ expected: &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{"pod-security.kubernetes.io/enforce": "restricted"},
+ },
+ },
+ },
+ {
+ name: "valid template with annotations",
+ annotations: map[string]string{
+ AnnotationSuggestedNamespaceTemplate: `{"metadata":{"annotations":{"openshift.io/description":"Operator namespace"}}}`,
+ },
+ expected: &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Annotations: map[string]string{"openshift.io/description": "Operator namespace"},
+ },
+ },
+ },
+ {
+ name: "invalid JSON",
+ annotations: map[string]string{AnnotationSuggestedNamespaceTemplate: `{"metadata": invalid json}`},
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := parseNamespaceTemplate(tt.annotations)
+ if tt.expectError {
+ require.Error(t, err)
+ assert.Nil(t, result)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestResolveSystemManagedNamespace(t *testing.T) {
+ tests := []struct {
+ name string
+ annotations map[string]string
+ packageName string
+ wantName string
+ wantTemplate bool
+ }{
+ {
+ name: "suggested-namespace-template with name",
+ annotations: map[string]string{AnnotationSuggestedNamespaceTemplate: `{"metadata":{"name":"from-template","labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}`},
+ packageName: "my-operator",
+ wantName: "from-template",
+ wantTemplate: true,
+ },
+ {
+ name: "suggested-namespace without template",
+ annotations: map[string]string{AnnotationSuggestedNamespace: "my-custom-ns"},
+ packageName: "my-operator",
+ wantName: "my-custom-ns",
+ },
+ {
+ name: "template takes priority over suggested-namespace",
+ annotations: map[string]string{
+ AnnotationSuggestedNamespaceTemplate: `{"metadata":{"name":"from-template"}}`,
+ AnnotationSuggestedNamespace: "from-annotation",
+ },
+ packageName: "my-operator",
+ wantName: "from-template",
+ wantTemplate: true,
+ },
+ {
+ name: "fallback to packageName-system",
+ annotations: map[string]string{},
+ packageName: "my-operator",
+ wantName: "my-operator-system",
+ },
+ {
+ name: "nil annotations fallback",
+ annotations: nil,
+ packageName: "my-operator",
+ wantName: "my-operator-system",
+ },
+ {
+ name: "template without name falls back to suggested-namespace",
+ annotations: map[string]string{
+ AnnotationSuggestedNamespaceTemplate: `{"metadata":{"labels":{"foo":"bar"}}}`,
+ AnnotationSuggestedNamespace: "from-annotation",
+ },
+ packageName: "my-operator",
+ wantName: "from-annotation",
+ wantTemplate: true,
+ },
+ {
+ name: "template without name and no suggested-namespace falls back to convention",
+ annotations: map[string]string{AnnotationSuggestedNamespaceTemplate: `{"metadata":{"labels":{"foo":"bar"}}}`},
+ packageName: "my-operator",
+ wantName: "my-operator-system",
+ wantTemplate: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ name, template, err := resolveSystemManagedNamespace(rv1WithAnnotations(tt.packageName, tt.annotations))
+ require.NoError(t, err)
+ require.Equal(t, tt.wantName, name)
+ if tt.wantTemplate {
+ require.NotNil(t, template)
+ } else {
+ require.Nil(t, template)
+ }
+ })
+ }
+}
+
+func TestResolveSystemManagedNamespace_InvalidTemplate(t *testing.T) {
+ _, _, err := resolveSystemManagedNamespace(rv1WithAnnotations("pkg", map[string]string{
+ AnnotationSuggestedNamespaceTemplate: `{invalid json`,
+ }))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "failed to parse namespace template")
+}
+
+func TestResolveSystemManagedNamespace_Validation(t *testing.T) {
+ tests := []struct {
+ name string
+ annotations map[string]string
+ packageName string
+ expectErr bool
+ errContains string
+ }{
+ {
+ name: "rejects uppercase characters in suggested-namespace",
+ annotations: map[string]string{AnnotationSuggestedNamespace: "Invalid-NS"},
+ packageName: "pkg",
+ expectErr: true,
+ errContains: "not a valid DNS1123 label",
+ },
+ {
+ name: "rejects name exceeding 63 characters",
+ annotations: map[string]string{AnnotationSuggestedNamespace: "a234567890123456789012345678901234567890123456789012345678901234"},
+ packageName: "pkg",
+ expectErr: true,
+ errContains: "exceeds 63 characters",
+ },
+ {
+ name: "rejects name with dots",
+ annotations: map[string]string{AnnotationSuggestedNamespace: "my.namespace"},
+ packageName: "pkg",
+ expectErr: true,
+ errContains: "not a valid DNS1123 label",
+ },
+ {
+ name: "accepts valid fallback name",
+ annotations: nil,
+ packageName: "my-package",
+ },
+ {
+ name: "accepts valid suggested-namespace",
+ annotations: map[string]string{AnnotationSuggestedNamespace: "valid-ns-123"},
+ packageName: "pkg",
+ },
+ {
+ name: "rejects invalid name from template",
+ annotations: map[string]string{AnnotationSuggestedNamespaceTemplate: `{"metadata":{"name":"INVALID"}}`},
+ packageName: "pkg",
+ expectErr: true,
+ errContains: "not a valid DNS1123 label",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, _, err := resolveSystemManagedNamespace(rv1WithAnnotations(tt.packageName, tt.annotations))
+ if tt.expectErr {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), tt.errContains)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestBuildNamespaceObject(t *testing.T) {
+ tests := []struct {
+ name string
+ nsName string
+ template *corev1.Namespace
+ validate func(t *testing.T, obj map[string]interface{})
+ }{
+ {
+ name: "with template labels",
+ nsName: "my-ns",
+ template: &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{"pod-security.kubernetes.io/enforce": "restricted"},
+ },
+ },
+ validate: func(t *testing.T, obj map[string]interface{}) {
+ assert.Equal(t, "v1", obj["apiVersion"])
+ assert.Equal(t, "Namespace", obj["kind"])
+ metadata := obj["metadata"].(map[string]interface{})
+ assert.Equal(t, "my-ns", metadata["name"])
+ labels := metadata["labels"].(map[string]interface{})
+ assert.Equal(t, "restricted", labels["pod-security.kubernetes.io/enforce"])
+ },
+ },
+ {
+ name: "nil template",
+ nsName: "my-ns",
+ template: nil,
+ validate: func(t *testing.T, obj map[string]interface{}) {
+ metadata := obj["metadata"].(map[string]interface{})
+ assert.Equal(t, "my-ns", metadata["name"])
+ _, hasLabels := metadata["labels"]
+ assert.False(t, hasLabels)
+ },
+ },
+ {
+ name: "template name is overridden",
+ nsName: "override",
+ template: &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{Name: "template-name"},
+ },
+ validate: func(t *testing.T, obj map[string]interface{}) {
+ metadata := obj["metadata"].(map[string]interface{})
+ assert.Equal(t, "override", metadata["name"])
+ },
+ },
+ {
+ name: "strips empty spec and status",
+ nsName: "my-ns",
+ validate: func(t *testing.T, obj map[string]interface{}) {
+ _, hasSpec := obj["spec"]
+ _, hasStatus := obj["status"]
+ assert.False(t, hasSpec)
+ assert.False(t, hasStatus)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := buildNamespaceObject(tt.nsName, tt.template)
+ require.NoError(t, err)
+ tt.validate(t, result.(*unstructured.Unstructured).Object)
+ })
+ }
+}
diff --git a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go
index f84a2305ed..7d827465c8 100644
--- a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go
+++ b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go
@@ -84,7 +84,7 @@ func Test_Renderer_Success(t *testing.T) {
},
}
- objs, err := registryv1.Renderer.Render(someBundle, "install-namespace")
+ objs, err := registryv1.Renderer.Render(someBundle, render.WithSelfManagedInstallNamespace("install-namespace"))
t.Log("Check renderer returns objects and no errors")
require.NoError(t, err)
require.NotEmpty(t, objs)
@@ -117,7 +117,7 @@ func Test_Renderer_Failure_UnsupportedKind(t *testing.T) {
},
}
- objs, err := registryv1.Renderer.Render(someBundle, "install-namespace")
+ objs, err := registryv1.Renderer.Render(someBundle, render.WithSelfManagedInstallNamespace("install-namespace"))
t.Log("Check renderer returns objects and no errors")
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported resource")
diff --git a/internal/operator-controller/rukpak/render/render.go b/internal/operator-controller/rukpak/render/render.go
index 86eb2ff492..3561312831 100644
--- a/internal/operator-controller/rukpak/render/render.go
+++ b/internal/operator-controller/rukpak/render/render.go
@@ -66,6 +66,11 @@ type Options struct {
// DeploymentConfig contains optional customizations to apply to CSV deployments.
// If nil, no customizations are applied.
DeploymentConfig *config.DeploymentConfig
+
+ // selfManagedNamespace records that the install namespace is managed by the
+ // caller (i.e. it was supplied via WithSelfManagedInstallNamespace). When false,
+ // the renderer resolves a system-managed namespace and emits a Namespace object.
+ selfManagedNamespace bool
}
func (o *Options) apply(opts ...Option) *Options {
@@ -90,6 +95,18 @@ func (o *Options) validate(rv1 *bundle.RegistryV1) (*Options, []error) {
type Option func(*Options)
+// WithSelfManagedInstallNamespace declares that the install namespace is managed by
+// the caller (e.g. the user set spec.namespace on the ClusterExtension). The renderer
+// renders resources into ns and does NOT emit a Namespace object. When this option is
+// absent, the renderer resolves a system-managed namespace from the bundle and emits
+// the corresponding Namespace object.
+func WithSelfManagedInstallNamespace(ns string) Option {
+ return func(o *Options) {
+ o.InstallNamespace = ns
+ o.selfManagedNamespace = true
+ }
+}
+
// WithTargetNamespaces sets the target namespaces to be used when rendering the bundle
// The value will only be used if len(namespaces) > 0. Otherwise, the default value for the bundle
// derived from its install mode support will be used (if such a value can be defined).
@@ -126,22 +143,34 @@ type BundleRenderer struct {
ResourceGenerators []ResourceGenerator
}
-func (r BundleRenderer) Render(rv1 bundle.RegistryV1, installNamespace string, opts ...Option) ([]client.Object, error) {
+func (r BundleRenderer) Render(rv1 bundle.RegistryV1, opts ...Option) ([]client.Object, error) {
// validate bundle
if err := r.BundleValidator.Validate(&rv1); err != nil {
return nil, err
}
- // generate bundle objects
- genOpts, errs := (&Options{
+ genOpts := (&Options{
// default options
- InstallNamespace: installNamespace,
TargetNamespaces: defaultTargetNamespacesForBundle(&rv1),
UniqueNameGenerator: DefaultUniqueNameGenerator,
CertificateProvider: nil,
- }).apply(opts...).validate(&rv1)
+ }).apply(opts...)
+
+ // When the install namespace is not caller-managed, resolve a system-managed
+ // namespace from the bundle and emit a Namespace object as part of the set.
+ var systemNamespace client.Object
+ if !genOpts.selfManagedNamespace {
+ name, template, err := resolveSystemManagedNamespace(&rv1)
+ if err != nil {
+ return nil, err
+ }
+ genOpts.InstallNamespace = name
+ if systemNamespace, err = buildNamespaceObject(name, template); err != nil {
+ return nil, err
+ }
+ }
- if len(errs) > 0 {
+ if _, errs := genOpts.validate(&rv1); len(errs) > 0 {
return nil, fmt.Errorf("invalid option(s): %w", errors.Join(errs...))
}
@@ -150,6 +179,10 @@ func (r BundleRenderer) Render(rv1 bundle.RegistryV1, installNamespace string, o
return nil, err
}
+ if systemNamespace != nil {
+ objs = append([]client.Object{systemNamespace}, objs...)
+ }
+
return objs, nil
}
diff --git a/internal/operator-controller/rukpak/render/render_test.go b/internal/operator-controller/rukpak/render/render_test.go
index fb24b7d3b1..11c1ccff4f 100644
--- a/internal/operator-controller/rukpak/render/render_test.go
+++ b/internal/operator-controller/rukpak/render/render_test.go
@@ -26,7 +26,7 @@ func Test_BundleRenderer_NoConfig(t *testing.T) {
objs, err := renderer.Render(
bundle.RegistryV1{
CSV: csv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(),
- }, "", nil)
+ }, render.WithSelfManagedInstallNamespace("install-namespace"), nil)
require.NoError(t, err)
require.Empty(t, objs)
}
@@ -39,7 +39,7 @@ func Test_BundleRenderer_ValidatesBundle(t *testing.T) {
},
},
}
- objs, err := renderer.Render(bundle.RegistryV1{}, "")
+ objs, err := renderer.Render(bundle.RegistryV1{}, render.WithSelfManagedInstallNamespace("install-namespace"))
require.Nil(t, objs)
require.Error(t, err)
require.Contains(t, err.Error(), "this bundle is invalid")
@@ -61,7 +61,7 @@ func Test_BundleRenderer_CreatesCorrectDefaultOptions(t *testing.T) {
},
}
- _, _ = renderer.Render(bundle.RegistryV1{}, expectedInstallNamespace)
+ _, _ = renderer.Render(bundle.RegistryV1{}, render.WithSelfManagedInstallNamespace(expectedInstallNamespace))
}
func Test_BundleRenderer_DefaultTargetNamespaces(t *testing.T) {
@@ -160,7 +160,7 @@ func Test_BundleRenderer_DefaultTargetNamespaces(t *testing.T) {
CSV: csv.Builder().
WithName("test").
WithInstallModeSupportFor(tc.supportedInstallModes...).Build(),
- }, "some-namespace")
+ }, render.WithSelfManagedInstallNamespace("some-namespace"))
if tc.expectedErrMsg != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedErrMsg)
@@ -283,8 +283,7 @@ func Test_BundleRenderer_ValidatesRenderOptions(t *testing.T) {
renderer := render.BundleRenderer{}
_, err := renderer.Render(
bundle.RegistryV1{CSV: tc.csv},
- tc.installNamespace,
- tc.opts...,
+ append([]render.Option{render.WithSelfManagedInstallNamespace(tc.installNamespace)}, tc.opts...)...,
)
if tc.err == nil {
require.NoError(t, err)
@@ -298,7 +297,7 @@ func Test_BundleRenderer_ValidatesRenderOptions(t *testing.T) {
func Test_BundleRenderer_AppliesUserOptions(t *testing.T) {
isOptionApplied := false
- _, _ = render.BundleRenderer{}.Render(bundle.RegistryV1{}, "install-namespace", func(options *render.Options) {
+ _, _ = render.BundleRenderer{}.Render(bundle.RegistryV1{}, render.WithSelfManagedInstallNamespace("install-namespace"), func(options *render.Options) {
isOptionApplied = true
})
require.True(t, isOptionApplied)
@@ -345,7 +344,7 @@ func Test_BundleRenderer_CallsResourceGenerators(t *testing.T) {
objs, err := renderer.Render(
bundle.RegistryV1{
CSV: csv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(),
- }, "")
+ }, render.WithSelfManagedInstallNamespace("install-namespace"))
require.NoError(t, err)
require.Equal(t, []client.Object{&corev1.Namespace{}, &corev1.Service{}, &appsv1.Deployment{}}, objs)
}
@@ -364,7 +363,7 @@ func Test_BundleRenderer_ReturnsResourceGeneratorErrors(t *testing.T) {
objs, err := renderer.Render(
bundle.RegistryV1{
CSV: csv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(),
- }, "")
+ }, render.WithSelfManagedInstallNamespace("install-namespace"))
require.Nil(t, objs)
require.Error(t, err)
require.Contains(t, err.Error(), "generator error")
@@ -408,7 +407,7 @@ func Test_WithDeploymentConfig(t *testing.T) {
bundle.RegistryV1{
CSV: csv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(),
},
- "test-namespace",
+ render.WithSelfManagedInstallNamespace("test-namespace"),
render.WithDeploymentConfig(expectedConfig),
)
@@ -431,7 +430,7 @@ func Test_WithDeploymentConfig(t *testing.T) {
bundle.RegistryV1{
CSV: csv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(),
},
- "test-namespace",
+ render.WithSelfManagedInstallNamespace("test-namespace"),
)
require.NoError(t, err)
@@ -453,7 +452,7 @@ func Test_WithDeploymentConfig(t *testing.T) {
bundle.RegistryV1{
CSV: csv.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(),
},
- "test-namespace",
+ render.WithSelfManagedInstallNamespace("test-namespace"),
render.WithDeploymentConfig(nil),
)
diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml
index 6d9346b4ae..33bbafaf14 100644
--- a/manifests/experimental-e2e.yaml
+++ b/manifests/experimental-e2e.yaml
@@ -761,12 +761,15 @@ spec:
rule: has(self.preflight)
namespace:
description: |-
- namespace specifies a Kubernetes namespace.
- It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- This namespace must exist.
+ namespace references an existing namespace where namespace-scoped resources
+ for the extension are applied. The namespace must already exist on the cluster.
- The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ namespace is optional. When omitted, operator-controller resolves and creates a
+ managed namespace from bundle metadata. The mode (set vs omitted) is locked at
+ creation time and cannot be changed. Omitting namespace requires the experimental
+ feature set (BoxcutterRuntime).
+
+ The namespace field follows the DNS label standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
and be no longer than 63 characters.
@@ -774,10 +777,13 @@ spec:
maxLength: 63
type: string
x-kubernetes-validations:
- - message: namespace is immutable
- rule: self == oldSelf
- message: namespace must be a valid DNS1123 label
- rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ rule: self == '' || self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ - message: namespace is immutable once set
+ rule: oldSelf == '' || self == oldSelf
+ - message: namespace cannot be set after creation; mode is locked
+ at creation time
+ rule: oldSelf != '' || self == ''
progressDeadlineMinutes:
description: |-
progressDeadlineMinutes is an optional field that defines the maximum period
@@ -1107,7 +1113,6 @@ spec:
rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ?
has(self.catalog) : !has(self.catalog)'
required:
- - namespace
- source
type: object
status:
diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml
index f8c3add53b..1527e87f2a 100644
--- a/manifests/experimental.yaml
+++ b/manifests/experimental.yaml
@@ -722,12 +722,15 @@ spec:
rule: has(self.preflight)
namespace:
description: |-
- namespace specifies a Kubernetes namespace.
- It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- This namespace must exist.
+ namespace references an existing namespace where namespace-scoped resources
+ for the extension are applied. The namespace must already exist on the cluster.
- The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ namespace is optional. When omitted, operator-controller resolves and creates a
+ managed namespace from bundle metadata. The mode (set vs omitted) is locked at
+ creation time and cannot be changed. Omitting namespace requires the experimental
+ feature set (BoxcutterRuntime).
+
+ The namespace field follows the DNS label standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
and be no longer than 63 characters.
@@ -735,10 +738,13 @@ spec:
maxLength: 63
type: string
x-kubernetes-validations:
- - message: namespace is immutable
- rule: self == oldSelf
- message: namespace must be a valid DNS1123 label
- rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ rule: self == '' || self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ - message: namespace is immutable once set
+ rule: oldSelf == '' || self == oldSelf
+ - message: namespace cannot be set after creation; mode is locked
+ at creation time
+ rule: oldSelf != '' || self == ''
progressDeadlineMinutes:
description: |-
progressDeadlineMinutes is an optional field that defines the maximum period
@@ -1068,7 +1074,6 @@ spec:
rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ?
has(self.catalog) : !has(self.catalog)'
required:
- - namespace
- source
type: object
status:
diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml
index 28dca6563d..2023edcd26 100644
--- a/manifests/standard-e2e.yaml
+++ b/manifests/standard-e2e.yaml
@@ -723,12 +723,12 @@ spec:
rule: has(self.preflight)
namespace:
description: |-
- namespace specifies a Kubernetes namespace.
- It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- This namespace must exist.
+ namespace references an existing namespace where namespace-scoped resources
+ for the extension are applied. The namespace must already exist on the cluster.
- The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ namespace is required.
+
+ The namespace field follows the DNS label standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
and be no longer than 63 characters.
@@ -736,10 +736,10 @@ spec:
maxLength: 63
type: string
x-kubernetes-validations:
- - message: namespace is immutable
- rule: self == oldSelf
- message: namespace must be a valid DNS1123 label
- rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ rule: self == '' || self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ - message: namespace is immutable once set
+ rule: oldSelf == '' || self == oldSelf
serviceAccount:
description: |-
serviceAccount is a deprecated field and is completely ignored.
@@ -1059,8 +1059,8 @@ spec:
rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ?
has(self.catalog) : !has(self.catalog)'
required:
- - namespace
- source
+ - namespace
type: object
status:
description: status is an optional field that defines the observed state
diff --git a/manifests/standard.yaml b/manifests/standard.yaml
index 71c7677772..cc4b448a60 100644
--- a/manifests/standard.yaml
+++ b/manifests/standard.yaml
@@ -684,12 +684,12 @@ spec:
rule: has(self.preflight)
namespace:
description: |-
- namespace specifies a Kubernetes namespace.
- It designates the default namespace where namespace-scoped resources for the extension are applied to the cluster.
- Some extensions may contain namespace-scoped resources to be applied in other namespaces.
- This namespace must exist.
+ namespace references an existing namespace where namespace-scoped resources
+ for the extension are applied. The namespace must already exist on the cluster.
- The namespace field is required, immutable, and follows the DNS label standard as defined in [RFC 1123].
+ namespace is required.
+
+ The namespace field follows the DNS label standard as defined in [RFC 1123].
It must contain only lowercase alphanumeric characters or hyphens (-), start and end with an alphanumeric character,
and be no longer than 63 characters.
@@ -697,10 +697,10 @@ spec:
maxLength: 63
type: string
x-kubernetes-validations:
- - message: namespace is immutable
- rule: self == oldSelf
- message: namespace must be a valid DNS1123 label
- rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ rule: self == '' || self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
+ - message: namespace is immutable once set
+ rule: oldSelf == '' || self == oldSelf
serviceAccount:
description: |-
serviceAccount is a deprecated field and is completely ignored.
@@ -1020,8 +1020,8 @@ spec:
rule: 'has(self.sourceType) && self.sourceType == ''Catalog'' ?
has(self.catalog) : !has(self.catalog)'
required:
- - namespace
- source
+ - namespace
type: object
status:
description: status is an optional field that defines the observed state
diff --git a/test/e2e/features/namespace.feature b/test/e2e/features/namespace.feature
new file mode 100644
index 0000000000..6da7e7838e
--- /dev/null
+++ b/test/e2e/features/namespace.feature
@@ -0,0 +1,62 @@
+Feature: Namespace PSA Management
+
+ As an OLM user, when I install an operator that declares PSA requirements
+ via the suggested-namespace-template CSV annotation, operator-controller
+ should create a managed namespace with PSA labels applied.
+
+ Background:
+ Given OLM is available
+ And an image registry is available
+
+ @BoxcutterRuntime
+ Scenario: Managed namespace with PSA template applies labels
+ Given a catalog "test" with packages:
+ | package | version | channel | replaces | contents |
+ | test | 1.0.0 | stable | | CRD, Deployment, NSTemplate(privileged) |
+ When ClusterExtension is applied
+ """
+ apiVersion: olm.operatorframework.io/v1
+ kind: ClusterExtension
+ metadata:
+ name: ${NAME}
+ spec:
+ source:
+ sourceType: Catalog
+ catalog:
+ packageName: ${PACKAGE:test}
+ selector:
+ matchLabels:
+ "olm.operatorframework.io/metadata.name": ${CATALOG:test}
+ """
+ Then ClusterExtension is rolled out
+ And ClusterExtension is available
+ And namespace "${PACKAGE:test}-system" has labels
+ | key | value |
+ | pod-security.kubernetes.io/enforce | privileged |
+ | pod-security.kubernetes.io/audit | privileged |
+ | pod-security.kubernetes.io/warn | privileged |
+
+ Scenario: User-provided namespace does not get PSA labels
+ Given namespace "${TEST_NAMESPACE}" is available
+ And a catalog "test" with packages:
+ | package | version | channel | replaces | contents |
+ | test | 1.0.0 | stable | | CRD, Deployment, ConfigMap |
+ When ClusterExtension is applied
+ """
+ apiVersion: olm.operatorframework.io/v1
+ kind: ClusterExtension
+ metadata:
+ name: ${NAME}
+ spec:
+ namespace: ${TEST_NAMESPACE}
+ source:
+ sourceType: Catalog
+ catalog:
+ packageName: ${PACKAGE:test}
+ selector:
+ matchLabels:
+ "olm.operatorframework.io/metadata.name": ${CATALOG:test}
+ """
+ Then ClusterExtension is rolled out
+ And ClusterExtension is available
+ And namespace "${TEST_NAMESPACE}" does not have label "pod-security.kubernetes.io/enforce"
diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go
index 31abf4bc0b..d3edd5169c 100644
--- a/test/e2e/steps/steps.go
+++ b/test/e2e/steps/steps.go
@@ -185,6 +185,9 @@ func RegisterSteps(sc *godog.ScenarioContext) {
sc.Step(`^(?i)catalog "([^"]+)" is labeled with "([^"]+)"$`, CatalogIsLabeledWith)
sc.Step(`^(?i)ValidatingAdmissionPolicy "([^"]+)" is active$`, ValidatingAdmissionPolicyIsActive)
+ sc.Step(`^(?i)namespace "([^"]+)" has labels$`, NamespaceHasLabels)
+ sc.Step(`^(?i)namespace "([^"]+)" does not have label "([^"]+)"$`, NamespaceDoesNotHaveLabel)
+
sc.Step(`^(?i)operator "([^"]+)" target namespace is "([^"]+)"$`, OperatorTargetNamespace)
sc.Step(`^(?i)Prometheus metrics are returned in the response$`, PrometheusMetricsAreReturned)
@@ -1968,6 +1971,10 @@ func parseContents(contents string) ([]catalog.BundleOption, error) {
dir := part[len("StaticBundleDir(") : len(part)-1]
absDir := filepath.Join(projectRootDir(), dir)
opts = append(opts, catalog.StaticBundleDir(absDir))
+ case strings.HasPrefix(part, "NSTemplate(") && strings.HasSuffix(part, ")"):
+ // NSTemplate(privileged) or NSTemplate(baseline) or NSTemplate(restricted)
+ level := part[len("NSTemplate(") : len(part)-1]
+ opts = append(opts, catalog.WithNSTemplate(level))
}
}
return opts, nil
@@ -2459,6 +2466,53 @@ func ResourceHasLabels(ctx context.Context, resourceName string, table *godog.Ta
return nil
}
+// NamespaceHasLabels waits for a namespace (cluster-scoped) to have all labels specified in the data table.
+func NamespaceHasLabels(ctx context.Context, nsName string, table *godog.Table) error {
+ sc := scenarioCtx(ctx)
+ nsName = substituteScenarioVars(nsName, sc)
+
+ expected, err := parseKeyValueTable(table, sc)
+ if err != nil {
+ return fmt.Errorf("invalid labels table: %w", err)
+ }
+
+ waitFor(ctx, func() bool {
+ out, err := k8sClient(ctx, "get", "namespace", nsName, "-o", "json")
+ if err != nil {
+ return false
+ }
+ var obj unstructured.Unstructured
+ if err := json.Unmarshal([]byte(out), &obj); err != nil {
+ return false
+ }
+ if key, got, ok := matchLabels(obj.GetLabels(), expected); !ok {
+ logger.V(1).Info("Namespace label not yet present or value mismatch", "namespace", nsName, "key", key, "expected", expected[key], "actual", got)
+ return false
+ }
+ return true
+ })
+ return nil
+}
+
+// NamespaceDoesNotHaveLabel verifies a namespace does not have the specified label.
+func NamespaceDoesNotHaveLabel(ctx context.Context, nsName string, labelKey string) error {
+ sc := scenarioCtx(ctx)
+ nsName = substituteScenarioVars(nsName, sc)
+
+ out, err := k8sClient(ctx, "get", "namespace", nsName, "-o", "json")
+ if err != nil {
+ return fmt.Errorf("failed to get namespace %q: %w", nsName, err)
+ }
+ var obj unstructured.Unstructured
+ if err := json.Unmarshal([]byte(out), &obj); err != nil {
+ return fmt.Errorf("failed to unmarshal namespace: %w", err)
+ }
+ if v, ok := obj.GetLabels()[labelKey]; ok {
+ return fmt.Errorf("namespace %q has unexpected label %s=%s", nsName, labelKey, v)
+ }
+ return nil
+}
+
// nestedString traverses a nested map[string]interface{} by the given keys
// and returns the leaf value as a string.
func nestedString(obj map[string]interface{}, keys ...string) (string, bool) {
diff --git a/test/internal/catalog/bundle.go b/test/internal/catalog/bundle.go
index 7bb80b5bce..491846c654 100644
--- a/test/internal/catalog/bundle.go
+++ b/test/internal/catalog/bundle.go
@@ -41,6 +41,7 @@ type bundleConfig struct {
largeCRDFieldCount int // if > 0, generate a CRD with this many fields
staticBundleDir string // if set, read bundle from this directory (no parameterization)
clusterRegistryOverride string // if set, use this host in the FBC image ref instead of the default
+ csvAnnotations map[string]string
}
// bundleSpec is the resolved bundle: version + file map ready for crane.Image().
@@ -109,6 +110,22 @@ func WithBundleProperty(propertyType, value string) BundleOption {
}
}
+// WithCSVAnnotation adds an annotation to the bundle's CSV.
+func WithCSVAnnotation(key, value string) BundleOption {
+ return func(c *bundleConfig) {
+ if c.csvAnnotations == nil {
+ c.csvAnnotations = make(map[string]string)
+ }
+ c.csvAnnotations[key] = value
+ }
+}
+
+// WithNSTemplate adds a suggested namespace template annotation to the CSV with the specified PSA level.
+func WithNSTemplate(psaLevel string) BundleOption {
+ template := fmt.Sprintf(`{"apiVersion":"v1","kind":"Namespace","metadata":{"labels":{"pod-security.kubernetes.io/enforce":"%s","pod-security.kubernetes.io/audit":"%s","pod-security.kubernetes.io/warn":"%s"}}}`, psaLevel, psaLevel, psaLevel)
+ return WithCSVAnnotation("operatorframework.io/suggested-namespace-template", template)
+}
+
// BadImage produces a bundle with CRD and deployment but uses "wrong/image" as
// the container image, causing ImagePullBackOff at runtime.
func BadImage() BundleOption {
@@ -164,6 +181,10 @@ func buildBundle(scenarioID, packageName, version string, opts []BundleOption) (
WithName(fmt.Sprintf("%s.v%s", packageName, version)).
WithInstallModeSupportFor(installModes...)
+ if len(cfg.csvAnnotations) > 0 {
+ csvBuilder = csvBuilder.WithAnnotations(cfg.csvAnnotations)
+ }
+
if cfg.hasCRD {
csvBuilder = csvBuilder.WithOwnedCRDs(v1alpha1.CRDDescription{
Name: crdName,
diff --git a/test/regression/convert/generate-manifests.go b/test/regression/convert/generate-manifests.go
index a3e3197e6e..5c52616be3 100644
--- a/test/regression/convert/generate-manifests.go
+++ b/test/regression/convert/generate-manifests.go
@@ -275,11 +275,14 @@ func generateManifests(outputPath, bundleDir, installNamespace, watchNamespace s
}
// Convert RegistryV1 to plain manifests
- opts := []render.Option{render.WithTargetNamespaces(watchNamespace)}
+ opts := []render.Option{
+ render.WithSelfManagedInstallNamespace(installNamespace),
+ render.WithTargetNamespaces(watchNamespace),
+ }
if deploymentConfig != nil {
opts = append(opts, render.WithDeploymentConfig(deploymentConfig))
}
- objs, err := registryv1.Renderer.Render(regv1, installNamespace, opts...)
+ objs, err := registryv1.Renderer.Render(regv1, opts...)
if err != nil {
return fmt.Errorf("error converting registry+v1 bundle: %w", err)
}