Skip to content

Commit 1eb4e4a

Browse files
fix: ClusterExtension configurations using watchNamespace: "" (empty string) no longer fail with a confusing validation error.
1 parent 6ef62de commit 1eb4e4a

3 files changed

Lines changed: 176 additions & 1 deletion

File tree

internal/operator-controller/config/config.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ func newConfig(data map[string]any) *Config {
7979
}
8080

8181
// GetWatchNamespace returns the watchNamespace value if present in the configuration.
82-
// Returns nil if watchNamespace is not set or is explicitly set to null.
82+
// Returns nil if watchNamespace is not set, is explicitly set to null, or is set to an empty string.
83+
// An empty string is treated as equivalent to "not configured" (AllNamespaces mode).
8384
func (c *Config) GetWatchNamespace() *string {
8485
if c == nil || *c == nil {
8586
return nil
@@ -95,6 +96,10 @@ func (c *Config) GetWatchNamespace() *string {
9596
// Convert value to string. Schema validation ensures this is a string,
9697
// but fmt.Sprintf handles edge cases defensively.
9798
str := fmt.Sprintf("%v", val)
99+
// Treat empty string as "not configured" (AllNamespaces mode)
100+
if str == "" {
101+
return nil
102+
}
98103
return &str
99104
}
100105

@@ -166,6 +171,11 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
166171
if !ok {
167172
return fmt.Errorf("value must be a string")
168173
}
174+
// Empty string is treated as "not configured" (AllNamespaces mode)
175+
// Skip validation for empty strings - they'll be normalized by GetWatchNamespace()
176+
if str == "" {
177+
return nil
178+
}
169179
if str != installNamespace {
170180
return fmt.Errorf("invalid value %q: watchNamespace must be %q (the namespace where the operator is installed) because this operator only supports OwnNamespace install mode", str, installNamespace)
171181
}
@@ -186,6 +196,11 @@ func validateConfigWithSchema(configBytes []byte, schema map[string]any, install
186196
if !ok {
187197
return fmt.Errorf("value must be a string")
188198
}
199+
// Empty string is treated as "not configured" (AllNamespaces mode)
200+
// Skip validation for empty strings - they'll be normalized by GetWatchNamespace()
201+
if str == "" {
202+
return nil
203+
}
189204
if str == installNamespace {
190205
return fmt.Errorf("invalid value %q: watchNamespace must be different from %q (the install namespace) because this operator uses SingleNamespace install mode to watch a different namespace", str, installNamespace)
191206
}

internal/operator-controller/config/config_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,27 @@ func Test_UnmarshalConfig(t *testing.T) {
275275
installNamespace: "",
276276
expectedWatchNamespace: ptr.To("valid-ns"),
277277
},
278+
{
279+
name: "accepts empty string watchNamespace (treated as AllNamespaces) when AllNamespaces+SingleNamespace",
280+
supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace},
281+
rawConfig: []byte(`{"watchNamespace": ""}`),
282+
installNamespace: "install-ns",
283+
expectedWatchNamespace: nil,
284+
},
285+
{
286+
name: "accepts empty string watchNamespace (treated as AllNamespaces) when only SingleNamespace",
287+
supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace},
288+
rawConfig: []byte(`{"watchNamespace": ""}`),
289+
installNamespace: "install-ns",
290+
expectedWatchNamespace: nil,
291+
},
292+
{
293+
name: "accepts empty string watchNamespace (treated as AllNamespaces) when only OwnNamespace",
294+
supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace},
295+
rawConfig: []byte(`{"watchNamespace": ""}`),
296+
installNamespace: "install-ns",
297+
expectedWatchNamespace: nil,
298+
},
278299
} {
279300
t.Run(tc.name, func(t *testing.T) {
280301
var rv1 bundle.RegistryV1

test/e2e/single_namespace_support_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,142 @@ func TestClusterExtensionVersionUpdate(t *testing.T) {
410410
require.Len(ct, cerList.Items, 2)
411411
}, pollDuration, pollInterval)
412412
}
413+
414+
func TestClusterExtensionEmptyWatchNamespace(t *testing.T) {
415+
SkipIfFeatureGateDisabled(t, soNsFlag)
416+
t.Log("Test support for empty watchNamespace treated as AllNamespaces mode")
417+
defer utils.CollectTestArtifacts(t, artifactName, c, cfg)
418+
419+
t.Log("By creating install namespace and necessary rbac resources")
420+
namespace := corev1.Namespace{
421+
ObjectMeta: metav1.ObjectMeta{
422+
Name: "empty-watchnamespace-operator",
423+
},
424+
}
425+
require.NoError(t, c.Create(t.Context(), &namespace))
426+
t.Cleanup(func() {
427+
require.NoError(t, c.Delete(context.Background(), &namespace))
428+
})
429+
430+
serviceAccount := corev1.ServiceAccount{
431+
ObjectMeta: metav1.ObjectMeta{
432+
Name: "empty-watchnamespace-operator-installer",
433+
Namespace: namespace.GetName(),
434+
},
435+
}
436+
require.NoError(t, c.Create(t.Context(), &serviceAccount))
437+
t.Cleanup(func() {
438+
require.NoError(t, c.Delete(context.Background(), &serviceAccount))
439+
})
440+
441+
clusterRoleBinding := &rbacv1.ClusterRoleBinding{
442+
ObjectMeta: metav1.ObjectMeta{
443+
Name: "empty-watchnamespace-operator-installer",
444+
},
445+
Subjects: []rbacv1.Subject{
446+
{
447+
Kind: "ServiceAccount",
448+
APIGroup: corev1.GroupName,
449+
Name: serviceAccount.GetName(),
450+
Namespace: serviceAccount.GetNamespace(),
451+
},
452+
},
453+
RoleRef: rbacv1.RoleRef{
454+
APIGroup: rbacv1.GroupName,
455+
Kind: "ClusterRole",
456+
Name: "cluster-admin",
457+
},
458+
}
459+
require.NoError(t, c.Create(t.Context(), clusterRoleBinding))
460+
t.Cleanup(func() {
461+
require.NoError(t, c.Delete(context.Background(), clusterRoleBinding))
462+
})
463+
464+
t.Log("By creating the test-catalog ClusterCatalog")
465+
extensionCatalog := &ocv1.ClusterCatalog{
466+
ObjectMeta: metav1.ObjectMeta{
467+
Name: "test-catalog",
468+
},
469+
Spec: ocv1.ClusterCatalogSpec{
470+
Source: ocv1.CatalogSource{
471+
Type: ocv1.SourceTypeImage,
472+
Image: &ocv1.ImageSource{
473+
Ref: fmt.Sprintf("%s/e2e/test-catalog:v1", os.Getenv("CLUSTER_REGISTRY_HOST")),
474+
PollIntervalMinutes: ptr.To(1),
475+
},
476+
},
477+
},
478+
}
479+
require.NoError(t, c.Create(t.Context(), extensionCatalog))
480+
t.Cleanup(func() {
481+
require.NoError(t, c.Delete(context.Background(), extensionCatalog))
482+
})
483+
484+
t.Log("By waiting for the catalog to serve its metadata")
485+
require.EventuallyWithT(t, func(ct *assert.CollectT) {
486+
require.NoError(ct, c.Get(context.Background(), types.NamespacedName{Name: extensionCatalog.GetName()}, extensionCatalog))
487+
cond := apimeta.FindStatusCondition(extensionCatalog.Status.Conditions, ocv1.TypeServing)
488+
require.NotNil(ct, cond)
489+
require.Equal(ct, metav1.ConditionTrue, cond.Status)
490+
require.Equal(ct, ocv1.ReasonAvailable, cond.Reason)
491+
}, pollDuration, pollInterval)
492+
493+
t.Log("By installing the single-namespace-operator ClusterExtension with empty watchNamespace")
494+
clusterExtension := &ocv1.ClusterExtension{
495+
ObjectMeta: metav1.ObjectMeta{
496+
Name: "empty-watchnamespace-operator-extension",
497+
},
498+
Spec: ocv1.ClusterExtensionSpec{
499+
Source: ocv1.SourceConfig{
500+
SourceType: "Catalog",
501+
Catalog: &ocv1.CatalogFilter{
502+
PackageName: "single-namespace-operator",
503+
Selector: &metav1.LabelSelector{
504+
MatchLabels: map[string]string{"olm.operatorframework.io/metadata.name": extensionCatalog.Name},
505+
},
506+
},
507+
},
508+
Namespace: namespace.GetName(),
509+
ServiceAccount: ocv1.ServiceAccountReference{
510+
Name: serviceAccount.GetName(),
511+
},
512+
Config: &ocv1.ClusterExtensionConfig{
513+
ConfigType: ocv1.ClusterExtensionConfigTypeInline,
514+
Inline: &apiextensionsv1.JSON{
515+
Raw: []byte(`{"watchNamespace": ""}`),
516+
},
517+
},
518+
},
519+
}
520+
require.NoError(t, c.Create(t.Context(), clusterExtension))
521+
t.Cleanup(func() {
522+
require.NoError(t, c.Delete(context.Background(), clusterExtension))
523+
})
524+
525+
t.Log("By waiting for the extension to be installed successfully (empty watchNamespace treated as AllNamespaces)")
526+
require.EventuallyWithT(t, func(ct *assert.CollectT) {
527+
require.NoError(ct, c.Get(t.Context(), types.NamespacedName{Name: clusterExtension.Name}, clusterExtension))
528+
cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
529+
require.NotNil(ct, cond)
530+
require.Equal(ct, metav1.ConditionTrue, cond.Status)
531+
require.Equal(ct, ocv1.ReasonSucceeded, cond.Reason)
532+
require.Contains(ct, cond.Message, "Installed bundle")
533+
require.NotNil(ct, clusterExtension.Status.Install)
534+
require.NotEmpty(ct, clusterExtension.Status.Install.Bundle)
535+
}, pollDuration, pollInterval)
536+
537+
t.Log("By verifying the deployment does not have watchNamespace annotation (should watch all namespaces)")
538+
require.EventuallyWithT(t, func(ct *assert.CollectT) {
539+
deployment := &appsv1.Deployment{}
540+
require.NoError(ct, c.Get(t.Context(), types.NamespacedName{Namespace: namespace.GetName(), Name: "single-namespace-operator"}, deployment))
541+
// When watchNamespace is empty/nil, the olm.targetNamespaces annotation should not be set
542+
// or should be empty, indicating the operator watches all namespaces
543+
annotations := deployment.Spec.Template.GetAnnotations()
544+
if annotations != nil {
545+
targetNs, exists := annotations["olm.targetNamespaces"]
546+
if exists {
547+
require.Empty(ct, targetNs, "olm.targetNamespaces should be empty for AllNamespaces mode")
548+
}
549+
}
550+
}, pollDuration, pollInterval)
551+
}

0 commit comments

Comments
 (0)