Repository: cloudnative-pg/cloudnative-pg

Issue:
[Feature]: Allow overriding webhook validation
### Is there an existing issue already for this feature request/idea?

- [x] I have searched for an existing issue, and could not find anything. I believe this is a new feature request to be evaluated.

### What problem is this feature going to solve? Why should it be added?

Currently, CloudNativePG prevents users from reducing the size of Persistent Volume Claims (PVCs) via its webhook validation. This restriction exists to prevent accidental misconfigurations and potential data loss, but it also prevents advanced users from handling storage reduction in controlled scenarios.

The core issue is that even if users are fully aware of the risks and are willing to methodically replace nodes one by one, they are blocked by the webhook validation, making it impossible to apply storage size reductions.

### Describe the solution you'd like

Instead of implementing partial workarounds that address specific corner cases, we should introduce a mechanism that allows users to explicitly override the webhook validation. This would enable advanced users to request the operator to accept new PVC sizes while bypassing the default restrictions.

One possible implementation could involve:

- An annotation (i.e., `cloudnative-pg.io/validation: disabled`) that signals the operator to skip any webhook validation and so accept the new PVC sizes.
- Proper documentation and warnings to ensure users understand the implications of skipping the validation.

The operator already has built-in safeguards to prevent dangerous actions, so this approach would keep the implementation simple while still providing flexibility.

A user who wants to shrink storage can follow [the procedure described in the last comment](https://github.com/cloudnative-pg/cloudnative-pg/issues/2405#issuecomment-2264889211) of issue #2405:
 
1. Set the new volume size.
2. Destroy all replicas one-by-one.
3. Promote a replica as the new primary.
4. Destroy the former primary as well.

This method ensures that the cluster eventually runs on the desired storage size without introducing unnecessary complexity in the operator logic.


### Describe alternatives you've considered

I've considered modifying the webhook to allow reductions in specific scenarios. However, this would require additional complexity and may not cover all valid use cases.


### Additional context

_No response_

### Backport?

No

### Are you willing to actively contribute to this feature?

Yes

### Code of Conduct

- [x] I agree to follow this project's Code of Conduct

Discussion:

Solution patch:
diff --git a/docs/src/labels_annotations.md b/docs/src/labels_annotations.md
index 69c48d6c..072a8f2d 100644
--- a/docs/src/labels_annotations.md
+++ b/docs/src/labels_annotations.md
@@ -228,6 +228,13 @@ CloudNativePG manages the following predefined annotations:
 `cnpg.io/snapshotEndTime`
 :   The time a snapshot was marked as ready to use.
 
+`cnpg.io/validation`
+:   When set to `disabled` on a CloudNativePG-managed custom resource, the
+    validation webhook allows all changes without restriction.
+
+    **⚠️ WARNING:** Disabling validation may permit unsafe or destructive
+    operations. Use this setting with caution and at your own risk.
+
 `cnpg.io/volumeSnapshotDeadline`
 :   Applied to `Backup` and `ScheduledBackup` resources, allows you to control
     how long the operator should retry recoverable errors before considering the
diff --git a/docs/src/release_notes/v1.26.md b/docs/src/release_notes/v1.26.md
index 92409602..0f0e517c 100644
--- a/docs/src/release_notes/v1.26.md
+++ b/docs/src/release_notes/v1.26.md
@@ -36,6 +36,10 @@ on the release branch in GitHub.
 
 ### Enhancements:
 
+- Implemented the `cnpg.io/validation` annotation, allowing users to disable
+  the validation webhook on CloudNativePG-managed resources. Use with caution,
+  as this can permit unrestricted changes. (#7196)
+
 - Introduced the `STANDBY_TCP_USER_TIMEOUT` operator configuration setting,
   which, if specified, sets the `tcp_user_timeout` parameter on all standby
   instances managed by the operator.
diff --git a/internal/webhook/v1/backup_webhook.go b/internal/webhook/v1/backup_webhook.go
index 7fbe2d56..b9582a81 100644
--- a/internal/webhook/v1/backup_webhook.go
+++ b/internal/webhook/v1/backup_webhook.go
@@ -43,7 +43,7 @@ var backupLog = log.WithName("backup-resource").WithValues("version", "v1")
 // SetupBackupWebhookWithManager registers the webhook for Backup in the manager.
 func SetupBackupWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Backup{}).
-		WithValidator(&BackupCustomValidator{}).
+		WithValidator(newBypassableValidator(&BackupCustomValidator{})).
 		WithDefaulter(&BackupCustomDefaulter{}).
 		Complete()
 }
diff --git a/internal/webhook/v1/cluster_webhook.go b/internal/webhook/v1/cluster_webhook.go
index 2e7f79aa..1671ce99 100644
--- a/internal/webhook/v1/cluster_webhook.go
+++ b/internal/webhook/v1/cluster_webhook.go
@@ -62,7 +62,7 @@ var clusterLog = log.WithName("cluster-resource").WithValues("version", "v1")
 // SetupClusterWebhookWithManager registers the webhook for Cluster in the manager.
 func SetupClusterWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Cluster{}).
-		WithValidator(&ClusterCustomValidator{}).
+		WithValidator(newBypassableValidator(&ClusterCustomValidator{})).
 		WithDefaulter(&ClusterCustomDefaulter{}).
 		Complete()
 }
diff --git a/internal/webhook/v1/common.go b/internal/webhook/v1/common.go
new file mode 100644
index 00000000..6cac64f9
--- /dev/null
+++ b/internal/webhook/v1/common.go
@@ -0,0 +1,133 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+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.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package v1
+
+import (
+	"context"
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/utils"
+)
+
+const (
+	// validationEnabledAnnotationValue is the value of that "validation"
+	// annotation that is set when the validation is enabled
+	validationEnabledAnnotationValue = "enabled"
+
+	// validationDisabledAnnotationValue is the value of that "validation"
+	// annotation that is set when the validation is disabled
+	validationDisabledAnnotationValue = "disabled"
+)
+
+// isValidationEnabled checks whether validation webhooks are
+// enabled or disabled
+func isValidationEnabled(obj client.Object) (bool, error) {
+	value := obj.GetAnnotations()[utils.WebhookValidationAnnotationName]
+	switch value {
+	case validationEnabledAnnotationValue, "":
+		return true, nil
+
+	case validationDisabledAnnotationValue:
+		return false, nil
+
+	default:
+		return true, fmt.Errorf(
+			`invalid %q annotation: %q (expected "enabled" or "disabled")`,
+			utils.WebhookValidationAnnotationName, value)
+	}
+}
+
+// bypassableValidator implements a custom validator that enables an
+// existing custom validator to be enabled or disabled via an annotation.
+type bypassableValidator struct {
+	validator admission.CustomValidator
+}
+
+// newBypassableValidator creates a new custom validator that enables an
+// existing custom validator to be enabled or disabled via an annotation.
+func newBypassableValidator(validator admission.CustomValidator) *bypassableValidator {
+	return &bypassableValidator{
+		validator: validator,
+	}
+}
+
+// ValidateCreate validates the object on creation.
+// The optional warnings will be added to the response as warning messages.
+// Return an error if the object is invalid.
+func (b bypassableValidator) ValidateCreate(
+	ctx context.Context,
+	obj runtime.Object,
+) (admission.Warnings, error) {
+	return validate(obj, func() (admission.Warnings, error) {
+		return b.validator.ValidateCreate(ctx, obj)
+	})
+}
+
+// ValidateUpdate validates the object on update.
+// The optional warnings will be added to the response as warning messages.
+// Return an error if the object is invalid.
+func (b bypassableValidator) ValidateUpdate(
+	ctx context.Context,
+	oldObj runtime.Object,
+	newObj runtime.Object,
+) (admission.Warnings, error) {
+	return validate(newObj, func() (admission.Warnings, error) {
+		return b.validator.ValidateUpdate(ctx, oldObj, newObj)
+	})
+}
+
+// ValidateDelete validates the object on deletion.
+// The optional warnings will be added to the response as warning messages.
+// Return an error if the object is invalid.
+func (b bypassableValidator) ValidateDelete(
+	ctx context.Context,
+	obj runtime.Object,
+) (admission.Warnings, error) {
+	return validate(obj, func() (admission.Warnings, error) {
+		return b.validator.ValidateDelete(ctx, obj)
+	})
+}
+
+const validationDisabledWarning = "validation webhook is disabled — all changes are accepted without validation. " +
+	"This may lead to unsafe or destructive operations. Proceed with extreme caution."
+
+func validate(obj runtime.Object, validator func() (admission.Warnings, error)) (admission.Warnings, error) {
+	var warnings admission.Warnings
+
+	validationEnabled, err := isValidationEnabled(obj.(client.Object))
+	if err != nil {
+		// If the validation annotation value is unexpected, we continue validating
+		// the object but we warn the user that the value was wrong
+		warnings = append(warnings, err.Error())
+	}
+
+	if !validationEnabled {
+		warnings = append(warnings, validationDisabledWarning)
+		return warnings, nil
+	}
+
+	validationWarnings, err := validator()
+	warnings = append(warnings, validationWarnings...)
+	return warnings, err
+}
diff --git a/internal/webhook/v1/database_webhook.go b/internal/webhook/v1/database_webhook.go
index f56cb569..34249556 100644
--- a/internal/webhook/v1/database_webhook.go
+++ b/internal/webhook/v1/database_webhook.go
@@ -41,7 +41,7 @@ var databaseLog = log.WithName("database-resource").WithValues("version", "v1")
 // SetupDatabaseWebhookWithManager registers the webhook for Database in the manager.
 func SetupDatabaseWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Database{}).
-		WithValidator(&DatabaseCustomValidator{}).
+		WithValidator(newBypassableValidator(&DatabaseCustomValidator{})).
 		WithDefaulter(&DatabaseCustomDefaulter{}).
 		Complete()
 }
diff --git a/internal/webhook/v1/pooler_webhook.go b/internal/webhook/v1/pooler_webhook.go
index dfbed90b..34594b06 100644
--- a/internal/webhook/v1/pooler_webhook.go
+++ b/internal/webhook/v1/pooler_webhook.go
@@ -97,7 +97,7 @@ var poolerLog = log.WithName("pooler-resource").WithValues("version", "v1")
 // SetupPoolerWebhookWithManager registers the webhook for Pooler in the manager.
 func SetupPoolerWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Pooler{}).
-		WithValidator(&PoolerCustomValidator{}).
+		WithValidator(newBypassableValidator(&PoolerCustomValidator{})).
 		Complete()
 }
 
diff --git a/pkg/utils/labels_annotations.go b/pkg/utils/labels_annotations.go
index 4023be22..37143c54 100644
--- a/pkg/utils/labels_annotations.go
+++ b/pkg/utils/labels_annotations.go
@@ -246,6 +246,10 @@ const (
 	// PodPatchAnnotationName is the name of the annotation containing the
 	// patch to apply to the pod
 	PodPatchAnnotationName = MetadataNamespace + "/podPatch"
+
+	// WebhookValidationAnnotationName is the name of the annotation describing if
+	// the validation webhook should be enabled or disabled
+	WebhookValidationAnnotationName = MetadataNamespace + "/validation"
 )
 
 type annotationStatus string



Test patch:
diff --git a/internal/webhook/v1/common_test.go b/internal/webhook/v1/common_test.go
new file mode 100644
index 00000000..126877e2
--- /dev/null
+++ b/internal/webhook/v1/common_test.go
@@ -0,0 +1,172 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+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.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package v1
+
+import (
+	"context"
+	"fmt"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/utils"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func newClusterWithValidationAnnotation(value string) *apiv1.Cluster {
+	return &apiv1.Cluster{
+		ObjectMeta: metav1.ObjectMeta{
+			Annotations: map[string]string{
+				utils.WebhookValidationAnnotationName: value,
+			},
+		},
+	}
+}
+
+var _ = Describe("Validation webhook validation parser", func() {
+	It("ensures that with no annotations the validation checking is enabled", func() {
+		cluster := &apiv1.Cluster{}
+		Expect(isValidationEnabled(cluster)).To(BeTrue())
+	})
+
+	It("ensures that with validation can be explicitly enabled", func() {
+		cluster := newClusterWithValidationAnnotation(validationEnabledAnnotationValue)
+		Expect(isValidationEnabled(cluster)).To(BeTrue())
+	})
+
+	It("ensures that with validation can be explicitly disabled", func() {
+		cluster := newClusterWithValidationAnnotation(validationDisabledAnnotationValue)
+		Expect(isValidationEnabled(cluster)).To(BeFalse())
+	})
+
+	It("ensures that with validation is enabled when the annotation value is unknown", func() {
+		cluster := newClusterWithValidationAnnotation("idontknow")
+		status, err := isValidationEnabled(cluster)
+		Expect(err).To(HaveOccurred())
+		Expect(status).To(BeTrue())
+	})
+})
+
+type fakeCustomValidator struct {
+	calls []string
+
+	createWarnings admission.Warnings
+	createError    error
+
+	updateWarnings admission.Warnings
+	updateError    error
+
+	deleteWarnings admission.Warnings
+	deleteError    error
+}
+
+func (f *fakeCustomValidator) ValidateCreate(
+	_ context.Context,
+	_ runtime.Object,
+) (admission.Warnings, error) {
+	f.calls = append(f.calls, "create")
+	return f.createWarnings, f.createError
+}
+
+func (f *fakeCustomValidator) ValidateUpdate(
+	_ context.Context,
+	_ runtime.Object,
+	_ runtime.Object,
+) (admission.Warnings, error) {
+	f.calls = append(f.calls, "update")
+	return f.updateWarnings, f.updateError
+}
+
+func (f *fakeCustomValidator) ValidateDelete(
+	_ context.Context,
+	_ runtime.Object,
+) (admission.Warnings, error) {
+	f.calls = append(f.calls, "delete")
+	return f.deleteWarnings, f.deleteError
+}
+
+var _ = Describe("Bypassable validator", func() {
+	fakeCreateError := fmt.Errorf("fake error")
+	fakeUpdateError := fmt.Errorf("fake error")
+	fakeDeleteError := fmt.Errorf("fake error")
+
+	disabledCluster := newClusterWithValidationAnnotation(validationDisabledAnnotationValue)
+	enabledCluster := newClusterWithValidationAnnotation(validationEnabledAnnotationValue)
+	wrongCluster := newClusterWithValidationAnnotation("dontknow")
+
+	fakeErrorValidator := &fakeCustomValidator{
+		createError: fakeCreateError,
+		deleteError: fakeDeleteError,
+		updateError: fakeUpdateError,
+	}
+
+	DescribeTable(
+		"validator callbacks",
+		func(ctx SpecContext, c *apiv1.Cluster, expectedError, withWarnings bool) {
+			b := newBypassableValidator(fakeErrorValidator)
+
+			By("creation entrypoint", func() {
+				result, err := b.ValidateCreate(ctx, c)
+				if expectedError {
+					Expect(err).To(Equal(fakeCreateError))
+				} else {
+					Expect(err).ToNot(HaveOccurred())
+				}
+
+				if withWarnings {
+					Expect(result).To(HaveLen(1))
+				}
+			})
+
+			By("update entrypoint", func() {
+				result, err := b.ValidateUpdate(ctx, enabledCluster, c)
+				if expectedError {
+					Expect(err).To(Equal(fakeUpdateError))
+				} else {
+					Expect(err).ToNot(HaveOccurred())
+				}
+
+				if withWarnings {
+					Expect(result).To(HaveLen(1))
+				}
+			})
+
+			By("delete entrypoint", func() {
+				result, err := b.ValidateDelete(ctx, c)
+				if expectedError {
+					Expect(err).To(Equal(fakeDeleteError))
+				} else {
+					Expect(err).ToNot(HaveOccurred())
+				}
+
+				if withWarnings {
+					Expect(result).To(HaveLen(1))
+				}
+			})
+		},
+		Entry("validation is disabled", disabledCluster, false, true),
+		Entry("validation is enabled", enabledCluster, true, false),
+		Entry("validation value is not expected", wrongCluster, true, true),
+	)
+})