Skip to content

Commit c037ca3

Browse files
leonardocearmrumnenciagbartolini
authored
feat: allow bypassing the validation webhook (cloudnative-pg#7196)
This patch introduces the `cnpg.io/validation` annotation, enabling users to disable the validation webhook on CloudNativePG-managed resources. This capability is essential for making unrestricted modifications to the cluster definition, allowing operations that the operator would typically prevent—such as reducing volume sizes and rebuilding them individually. However, this increased flexibility comes with the risk of executing potentially harmful operations, so it should be used with caution. Fixes: cloudnative-pg#7117 Signed-off-by: Leonardo Cecchi <leonardo.cecchi@enterprisedb.com> Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com> Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com> Signed-off-by: Gabriele Bartolini <gabriele.bartolini@enterprisedb.com> Co-authored-by: Armando Ruocco <armando.ruocco@enterprisedb.com> Co-authored-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com> Co-authored-by: Gabriele Bartolini <gabriele.bartolini@enterprisedb.com>
1 parent a2373e5 commit c037ca3

File tree

9 files changed

+324
-4
lines changed

9 files changed

+324
-4
lines changed

docs/src/labels_annotations.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ CloudNativePG manages the following predefined annotations:
228228
`cnpg.io/snapshotEndTime`
229229
: The time a snapshot was marked as ready to use.
230230

231+
`cnpg.io/validation`
232+
: When set to `disabled` on a CloudNativePG-managed custom resource, the
233+
validation webhook allows all changes without restriction.
234+
235+
**⚠️ WARNING:** Disabling validation may permit unsafe or destructive
236+
operations. Use this setting with caution and at your own risk.
237+
231238
`cnpg.io/volumeSnapshotDeadline`
232239
: Applied to `Backup` and `ScheduledBackup` resources, allows you to control
233240
how long the operator should retry recoverable errors before considering the

docs/src/release_notes/v1.26.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ on the release branch in GitHub.
3636

3737
### Enhancements:
3838

39+
- Implemented the `cnpg.io/validation` annotation, allowing users to disable
40+
the validation webhook on CloudNativePG-managed resources. Use with caution,
41+
as this can permit unrestricted changes. (#7196)
42+
3943
- Introduced the `STANDBY_TCP_USER_TIMEOUT` operator configuration setting,
4044
which, if specified, sets the `tcp_user_timeout` parameter on all standby
4145
instances managed by the operator.

internal/webhook/v1/backup_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ var backupLog = log.WithName("backup-resource").WithValues("version", "v1")
4343
// SetupBackupWebhookWithManager registers the webhook for Backup in the manager.
4444
func SetupBackupWebhookWithManager(mgr ctrl.Manager) error {
4545
return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Backup{}).
46-
WithValidator(&BackupCustomValidator{}).
46+
WithValidator(newBypassableValidator(&BackupCustomValidator{})).
4747
WithDefaulter(&BackupCustomDefaulter{}).
4848
Complete()
4949
}

internal/webhook/v1/cluster_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ var clusterLog = log.WithName("cluster-resource").WithValues("version", "v1")
6262
// SetupClusterWebhookWithManager registers the webhook for Cluster in the manager.
6363
func SetupClusterWebhookWithManager(mgr ctrl.Manager) error {
6464
return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Cluster{}).
65-
WithValidator(&ClusterCustomValidator{}).
65+
WithValidator(newBypassableValidator(&ClusterCustomValidator{})).
6666
WithDefaulter(&ClusterCustomDefaulter{}).
6767
Complete()
6868
}

internal/webhook/v1/common.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
Copyright © contributors to CloudNativePG, established as
3+
CloudNativePG a Series of LF Projects, LLC.
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
17+
SPDX-License-Identifier: Apache-2.0
18+
*/
19+
20+
package v1
21+
22+
import (
23+
"context"
24+
"fmt"
25+
26+
"k8s.io/apimachinery/pkg/runtime"
27+
"sigs.k8s.io/controller-runtime/pkg/client"
28+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
29+
30+
"github.com/cloudnative-pg/cloudnative-pg/pkg/utils"
31+
)
32+
33+
const (
34+
// validationEnabledAnnotationValue is the value of that "validation"
35+
// annotation that is set when the validation is enabled
36+
validationEnabledAnnotationValue = "enabled"
37+
38+
// validationDisabledAnnotationValue is the value of that "validation"
39+
// annotation that is set when the validation is disabled
40+
validationDisabledAnnotationValue = "disabled"
41+
)
42+
43+
// isValidationEnabled checks whether validation webhooks are
44+
// enabled or disabled
45+
func isValidationEnabled(obj client.Object) (bool, error) {
46+
value := obj.GetAnnotations()[utils.WebhookValidationAnnotationName]
47+
switch value {
48+
case validationEnabledAnnotationValue, "":
49+
return true, nil
50+
51+
case validationDisabledAnnotationValue:
52+
return false, nil
53+
54+
default:
55+
return true, fmt.Errorf(
56+
`invalid %q annotation: %q (expected "enabled" or "disabled")`,
57+
utils.WebhookValidationAnnotationName, value)
58+
}
59+
}
60+
61+
// bypassableValidator implements a custom validator that enables an
62+
// existing custom validator to be enabled or disabled via an annotation.
63+
type bypassableValidator struct {
64+
validator admission.CustomValidator
65+
}
66+
67+
// newBypassableValidator creates a new custom validator that enables an
68+
// existing custom validator to be enabled or disabled via an annotation.
69+
func newBypassableValidator(validator admission.CustomValidator) *bypassableValidator {
70+
return &bypassableValidator{
71+
validator: validator,
72+
}
73+
}
74+
75+
// ValidateCreate validates the object on creation.
76+
// The optional warnings will be added to the response as warning messages.
77+
// Return an error if the object is invalid.
78+
func (b bypassableValidator) ValidateCreate(
79+
ctx context.Context,
80+
obj runtime.Object,
81+
) (admission.Warnings, error) {
82+
return validate(obj, func() (admission.Warnings, error) {
83+
return b.validator.ValidateCreate(ctx, obj)
84+
})
85+
}
86+
87+
// ValidateUpdate validates the object on update.
88+
// The optional warnings will be added to the response as warning messages.
89+
// Return an error if the object is invalid.
90+
func (b bypassableValidator) ValidateUpdate(
91+
ctx context.Context,
92+
oldObj runtime.Object,
93+
newObj runtime.Object,
94+
) (admission.Warnings, error) {
95+
return validate(newObj, func() (admission.Warnings, error) {
96+
return b.validator.ValidateUpdate(ctx, oldObj, newObj)
97+
})
98+
}
99+
100+
// ValidateDelete validates the object on deletion.
101+
// The optional warnings will be added to the response as warning messages.
102+
// Return an error if the object is invalid.
103+
func (b bypassableValidator) ValidateDelete(
104+
ctx context.Context,
105+
obj runtime.Object,
106+
) (admission.Warnings, error) {
107+
return validate(obj, func() (admission.Warnings, error) {
108+
return b.validator.ValidateDelete(ctx, obj)
109+
})
110+
}
111+
112+
const validationDisabledWarning = "validation webhook is disabled — all changes are accepted without validation. " +
113+
"This may lead to unsafe or destructive operations. Proceed with extreme caution."
114+
115+
func validate(obj runtime.Object, validator func() (admission.Warnings, error)) (admission.Warnings, error) {
116+
var warnings admission.Warnings
117+
118+
validationEnabled, err := isValidationEnabled(obj.(client.Object))
119+
if err != nil {
120+
// If the validation annotation value is unexpected, we continue validating
121+
// the object but we warn the user that the value was wrong
122+
warnings = append(warnings, err.Error())
123+
}
124+
125+
if !validationEnabled {
126+
warnings = append(warnings, validationDisabledWarning)
127+
return warnings, nil
128+
}
129+
130+
validationWarnings, err := validator()
131+
warnings = append(warnings, validationWarnings...)
132+
return warnings, err
133+
}

internal/webhook/v1/common_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*
2+
Copyright © contributors to CloudNativePG, established as
3+
CloudNativePG a Series of LF Projects, LLC.
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
17+
SPDX-License-Identifier: Apache-2.0
18+
*/
19+
20+
package v1
21+
22+
import (
23+
"context"
24+
"fmt"
25+
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
29+
30+
apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
31+
"github.com/cloudnative-pg/cloudnative-pg/pkg/utils"
32+
33+
. "github.com/onsi/ginkgo/v2"
34+
. "github.com/onsi/gomega"
35+
)
36+
37+
func newClusterWithValidationAnnotation(value string) *apiv1.Cluster {
38+
return &apiv1.Cluster{
39+
ObjectMeta: metav1.ObjectMeta{
40+
Annotations: map[string]string{
41+
utils.WebhookValidationAnnotationName: value,
42+
},
43+
},
44+
}
45+
}
46+
47+
var _ = Describe("Validation webhook validation parser", func() {
48+
It("ensures that with no annotations the validation checking is enabled", func() {
49+
cluster := &apiv1.Cluster{}
50+
Expect(isValidationEnabled(cluster)).To(BeTrue())
51+
})
52+
53+
It("ensures that with validation can be explicitly enabled", func() {
54+
cluster := newClusterWithValidationAnnotation(validationEnabledAnnotationValue)
55+
Expect(isValidationEnabled(cluster)).To(BeTrue())
56+
})
57+
58+
It("ensures that with validation can be explicitly disabled", func() {
59+
cluster := newClusterWithValidationAnnotation(validationDisabledAnnotationValue)
60+
Expect(isValidationEnabled(cluster)).To(BeFalse())
61+
})
62+
63+
It("ensures that with validation is enabled when the annotation value is unknown", func() {
64+
cluster := newClusterWithValidationAnnotation("idontknow")
65+
status, err := isValidationEnabled(cluster)
66+
Expect(err).To(HaveOccurred())
67+
Expect(status).To(BeTrue())
68+
})
69+
})
70+
71+
type fakeCustomValidator struct {
72+
calls []string
73+
74+
createWarnings admission.Warnings
75+
createError error
76+
77+
updateWarnings admission.Warnings
78+
updateError error
79+
80+
deleteWarnings admission.Warnings
81+
deleteError error
82+
}
83+
84+
func (f *fakeCustomValidator) ValidateCreate(
85+
_ context.Context,
86+
_ runtime.Object,
87+
) (admission.Warnings, error) {
88+
f.calls = append(f.calls, "create")
89+
return f.createWarnings, f.createError
90+
}
91+
92+
func (f *fakeCustomValidator) ValidateUpdate(
93+
_ context.Context,
94+
_ runtime.Object,
95+
_ runtime.Object,
96+
) (admission.Warnings, error) {
97+
f.calls = append(f.calls, "update")
98+
return f.updateWarnings, f.updateError
99+
}
100+
101+
func (f *fakeCustomValidator) ValidateDelete(
102+
_ context.Context,
103+
_ runtime.Object,
104+
) (admission.Warnings, error) {
105+
f.calls = append(f.calls, "delete")
106+
return f.deleteWarnings, f.deleteError
107+
}
108+
109+
var _ = Describe("Bypassable validator", func() {
110+
fakeCreateError := fmt.Errorf("fake error")
111+
fakeUpdateError := fmt.Errorf("fake error")
112+
fakeDeleteError := fmt.Errorf("fake error")
113+
114+
disabledCluster := newClusterWithValidationAnnotation(validationDisabledAnnotationValue)
115+
enabledCluster := newClusterWithValidationAnnotation(validationEnabledAnnotationValue)
116+
wrongCluster := newClusterWithValidationAnnotation("dontknow")
117+
118+
fakeErrorValidator := &fakeCustomValidator{
119+
createError: fakeCreateError,
120+
deleteError: fakeDeleteError,
121+
updateError: fakeUpdateError,
122+
}
123+
124+
DescribeTable(
125+
"validator callbacks",
126+
func(ctx SpecContext, c *apiv1.Cluster, expectedError, withWarnings bool) {
127+
b := newBypassableValidator(fakeErrorValidator)
128+
129+
By("creation entrypoint", func() {
130+
result, err := b.ValidateCreate(ctx, c)
131+
if expectedError {
132+
Expect(err).To(Equal(fakeCreateError))
133+
} else {
134+
Expect(err).ToNot(HaveOccurred())
135+
}
136+
137+
if withWarnings {
138+
Expect(result).To(HaveLen(1))
139+
}
140+
})
141+
142+
By("update entrypoint", func() {
143+
result, err := b.ValidateUpdate(ctx, enabledCluster, c)
144+
if expectedError {
145+
Expect(err).To(Equal(fakeUpdateError))
146+
} else {
147+
Expect(err).ToNot(HaveOccurred())
148+
}
149+
150+
if withWarnings {
151+
Expect(result).To(HaveLen(1))
152+
}
153+
})
154+
155+
By("delete entrypoint", func() {
156+
result, err := b.ValidateDelete(ctx, c)
157+
if expectedError {
158+
Expect(err).To(Equal(fakeDeleteError))
159+
} else {
160+
Expect(err).ToNot(HaveOccurred())
161+
}
162+
163+
if withWarnings {
164+
Expect(result).To(HaveLen(1))
165+
}
166+
})
167+
},
168+
Entry("validation is disabled", disabledCluster, false, true),
169+
Entry("validation is enabled", enabledCluster, true, false),
170+
Entry("validation value is not expected", wrongCluster, true, true),
171+
)
172+
})

internal/webhook/v1/database_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ var databaseLog = log.WithName("database-resource").WithValues("version", "v1")
4141
// SetupDatabaseWebhookWithManager registers the webhook for Database in the manager.
4242
func SetupDatabaseWebhookWithManager(mgr ctrl.Manager) error {
4343
return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Database{}).
44-
WithValidator(&DatabaseCustomValidator{}).
44+
WithValidator(newBypassableValidator(&DatabaseCustomValidator{})).
4545
WithDefaulter(&DatabaseCustomDefaulter{}).
4646
Complete()
4747
}

internal/webhook/v1/pooler_webhook.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ var poolerLog = log.WithName("pooler-resource").WithValues("version", "v1")
9797
// SetupPoolerWebhookWithManager registers the webhook for Pooler in the manager.
9898
func SetupPoolerWebhookWithManager(mgr ctrl.Manager) error {
9999
return ctrl.NewWebhookManagedBy(mgr).For(&apiv1.Pooler{}).
100-
WithValidator(&PoolerCustomValidator{}).
100+
WithValidator(newBypassableValidator(&PoolerCustomValidator{})).
101101
Complete()
102102
}
103103

pkg/utils/labels_annotations.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ const (
246246
// PodPatchAnnotationName is the name of the annotation containing the
247247
// patch to apply to the pod
248248
PodPatchAnnotationName = MetadataNamespace + "/podPatch"
249+
250+
// WebhookValidationAnnotationName is the name of the annotation describing if
251+
// the validation webhook should be enabled or disabled
252+
WebhookValidationAnnotationName = MetadataNamespace + "/validation"
249253
)
250254

251255
type annotationStatus string

0 commit comments

Comments
 (0)