From 9c32775980127cdef66fad9521d82011405fa793 Mon Sep 17 00:00:00 2001 From: Nader Ziada Date: Wed, 16 Sep 2026 14:21:24 -0400 Subject: [PATCH] feat(applier): support a system-managed install namespace at runtime When spec.namespace is empty the applier stops passing WithSelfManagedInstallNamespace, so the renderer resolves the install namespace from bundle metadata and emits the Namespace object itself. This is gated on BoxcutterRuntime; with the gate off an empty spec.namespace is a terminal configuration error rather than a silent fallback. Signed-off-by: Nader Ziada --- cmd/operator-controller/main.go | 12 +- .../operator-controller/applier/boxcutter.go | 5 + .../applier/boxcutter_test.go | 43 ++++-- .../operator-controller/applier/provider.go | 23 +++- .../applier/provider_test.go | 122 ++++++++++++++++-- .../clusterextension_controller_test.go | 95 ++++++++++++++ .../clusterextension_reconcile_steps.go | 31 +++++ 7 files changed, 301 insertions(+), 30 deletions(-) diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 2fcea83ef0..b6ea5c6823 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -502,11 +502,12 @@ func run() error { certProvider := getCertificateProvider() regv1ManifestProvider := &applier.RegistryV1ManifestProvider{ - BundleRenderer: registryv1.Renderer, - CertificateProvider: certProvider, - IsWebhookSupportEnabled: certProvider != nil, - IsSingleOwnNamespaceEnabled: features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport), - IsDeploymentConfigEnabled: features.OperatorControllerFeatureGate.Enabled(features.DeploymentConfig), + BundleRenderer: registryv1.Renderer, + CertificateProvider: certProvider, + IsWebhookSupportEnabled: certProvider != nil, + IsSingleOwnNamespaceEnabled: features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport), + IsDeploymentConfigEnabled: features.OperatorControllerFeatureGate.Enabled(features.DeploymentConfig), + IsNamespaceManagementEnabled: 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), } diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index a52fa21c7e..5914e864c4 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -273,6 +273,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..d33c6aa55e 100644 --- a/internal/operator-controller/applier/boxcutter_test.go +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -1141,7 +1141,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 +1214,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 +1269,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 +1342,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 +1425,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 +1482,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 +1579,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 +1626,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) @@ -1650,4 +1650,31 @@ func TestBoxcutterStorageMigrator(t *testing.T) { err := sm.Migrate(t.Context(), ext, map[string]string{"my-label": "my-value"}) require.NoError(t, err) }) + + t.Run("skips migration for managed namespace mode (empty spec.namespace)", func(t *testing.T) { + testScheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(testScheme)) + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: "test123"}, + } + ctrl := gomock.NewController(t) + // A managed-namespace extension never had a Helm release, so migration must be a no-op: + // no List, no action client, and no revision generation. No expectations are set, so + // gomock fails the test if any of these are called. + brb := mockapplier.NewMockClusterObjectSetGenerator(ctrl) + mag := newMockActionGetter(ctrl, mockActionGetterConfig{}) + mockClient := mockctrlclient.NewMockClient(ctrl) + + sm := &applier.BoxcutterStorageMigrator{ + RevisionGenerator: brb, + ActionClientGetter: mag, + Client: mockClient, + Scheme: testScheme, + FieldOwner: "test-owner", + } + + err := sm.Migrate(t.Context(), ext, map[string]string{"my-label": "my-value"}) + require.NoError(t, err) + }) } diff --git a/internal/operator-controller/applier/provider.go b/internal/operator-controller/applier/provider.go index 77343cf9da..d4db0a8570 100644 --- a/internal/operator-controller/applier/provider.go +++ b/internal/operator-controller/applier/provider.go @@ -29,11 +29,12 @@ type ManifestProvider interface { // RegistryV1ManifestProvider generates the manifests that should be installed for a registry+v1 bundle // given the user specified configuration given by the ClusterExtension API surface type RegistryV1ManifestProvider struct { - BundleRenderer render.BundleRenderer - CertificateProvider render.CertificateProvider - IsWebhookSupportEnabled bool - IsSingleOwnNamespaceEnabled bool - IsDeploymentConfigEnabled bool + BundleRenderer render.BundleRenderer + CertificateProvider render.CertificateProvider + IsWebhookSupportEnabled bool + IsSingleOwnNamespaceEnabled bool + IsDeploymentConfigEnabled bool + IsNamespaceManagementEnabled bool } func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) { @@ -67,9 +68,19 @@ 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.IsNamespaceManagementEnabled { + 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), - render.WithSelfManagedInstallNamespace(ext.Spec.Namespace), + } + + // When the user set spec.namespace, render into that caller-managed (already-existing) + // namespace and do not emit a Namespace object. Otherwise the renderer resolves the + // bundle's system-managed namespace and emits the Namespace object for it. + if ext.Spec.Namespace != "" { + opts = append(opts, render.WithSelfManagedInstallNamespace(ext.Spec.Namespace)) } // Always validate inline config when present so that disabled features produce diff --git a/internal/operator-controller/applier/provider_test.go b/internal/operator-controller/applier/provider_test.go index 6fb9760417..d1b26faf54 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, + IsNamespaceManagementEnabled: 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 spec.namespace is set") + } + }) +} + +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, + IsNamespaceManagementEnabled: 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, + IsNamespaceManagementEnabled: 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, + IsNamespaceManagementEnabled: 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_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go index 2637457752..5f721adb8e 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,98 @@ 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"}}, + }, + }, + { + name: "user-provided namespace not found", + specNamespace: "missing-ns", + expectError: true, + errorMessageIncludes: `namespace "missing-ns" not found`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + fakeClient := fake.NewClientset(tt.namespaceObjects...) + + 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.NoError(t, err) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{})) + return + } + + 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) + // A missing namespace is retryable (not terminal): the user can create it and + // the next reconcile succeeds, so Progressing stays True with Reason=Retrying. + require.Equal(t, metav1.ConditionTrue, progressingCond.Status) + require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason) + require.Contains(t, progressingCond.Message, tt.errorMessageIncludes) + require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{})) + }) + } +} + +// The CRD still requires a non-empty spec.namespace, so this case cannot be driven through the +// API server. Call the step directly to cover the system-managed short-circuit. +func TestValidateInstallNamespaceSkipsSystemManaged(t *testing.T) { + fakeClient := fake.NewClientset() + step := controllers.ValidateInstallNamespace(fakeClient.CoreV1()) + + res, err := step(context.Background(), nil, &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: "test-extension"}, + }) + + require.NoError(t, err) + require.Nil(t, res) + require.Empty(t, fakeClient.Actions(), "no namespace lookup should happen for a system-managed namespace") +} + 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..c541202d2d 100644 --- a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go +++ b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go @@ -21,8 +21,10 @@ 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" @@ -402,6 +404,35 @@ func UnpackBundle(i imageutil.Puller, cache imageutil.Cache) ReconcileStepFunc { } } +// ValidateInstallNamespace verifies that a user-provided spec.namespace exists. +// +// A missing namespace is recoverable — the user can create it — so it is surfaced as a retryable +// error rather than a terminal one: the next reconcile succeeds once the namespace exists. +// +// When spec.namespace is omitted the namespace is system-managed and the renderer creates it, so +// there is nothing to check. +func ValidateInstallNamespace(nsClient corev1client.NamespacesGetter) ReconcileStepFunc { + return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) { + if ext.Spec.Namespace == "" { + return nil, nil + } + + l := log.FromContext(ctx) + 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) { + nsErr := fmt.Errorf("namespace %q not found; spec.namespace must reference an existing namespace", ext.Spec.Namespace) + setStatusProgressing(ext, nsErr) + return nil, nsErr + } + 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)