Skip to content

Commit 48ec3b7

Browse files
🐛 Fix custom defaulter: avoid deleting unknown fields (zero change patch) (#2982)
* Fix custom defaulter from deleting unknown fields * Use a zero-change patch to do find scheme caused fields removal. * Add `DefaulterPreserveUnknownFields` An option stops the defaulter from pruning the fields that are not recognized in the local scheme. * Make it opt-out, `DefaulterRemoveUnknownFields` * Review Remarks * Review Remarks * Rename DefaulterRemoveUnknownFields to DefaulterRemoveUnknownOrOmitableFields --------- Co-authored-by: Aldo Culquicondor <acondor@google.com>
1 parent 96fc314 commit 48ec3b7

File tree

3 files changed

+159
-21
lines changed

3 files changed

+159
-21
lines changed

pkg/builder/webhook.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,17 @@ import (
3737

3838
// WebhookBuilder builds a Webhook.
3939
type WebhookBuilder struct {
40-
apiType runtime.Object
41-
customDefaulter admission.CustomDefaulter
42-
customValidator admission.CustomValidator
43-
customPath string
44-
gvk schema.GroupVersionKind
45-
mgr manager.Manager
46-
config *rest.Config
47-
recoverPanic *bool
48-
logConstructor func(base logr.Logger, req *admission.Request) logr.Logger
49-
err error
40+
apiType runtime.Object
41+
customDefaulter admission.CustomDefaulter
42+
customDefaulterOpts []admission.DefaulterOption
43+
customValidator admission.CustomValidator
44+
customPath string
45+
gvk schema.GroupVersionKind
46+
mgr manager.Manager
47+
config *rest.Config
48+
recoverPanic *bool
49+
logConstructor func(base logr.Logger, req *admission.Request) logr.Logger
50+
err error
5051
}
5152

5253
// WebhookManagedBy returns a new webhook builder.
@@ -67,9 +68,11 @@ func (blder *WebhookBuilder) For(apiType runtime.Object) *WebhookBuilder {
6768
return blder
6869
}
6970

70-
// WithDefaulter takes an admission.CustomDefaulter interface, a MutatingWebhook will be wired for this type.
71-
func (blder *WebhookBuilder) WithDefaulter(defaulter admission.CustomDefaulter) *WebhookBuilder {
71+
// WithDefaulter takes an admission.CustomDefaulter interface, a MutatingWebhook with the provided opts (admission.DefaulterOption)
72+
// will be wired for this type.
73+
func (blder *WebhookBuilder) WithDefaulter(defaulter admission.CustomDefaulter, opts ...admission.DefaulterOption) *WebhookBuilder {
7274
blder.customDefaulter = defaulter
75+
blder.customDefaulterOpts = opts
7376
return blder
7477
}
7578

@@ -194,7 +197,7 @@ func (blder *WebhookBuilder) registerDefaultingWebhook() error {
194197

195198
func (blder *WebhookBuilder) getDefaultingWebhook() *admission.Webhook {
196199
if defaulter := blder.customDefaulter; defaulter != nil {
197-
w := admission.WithCustomDefaulter(blder.mgr.GetScheme(), blder.apiType, defaulter)
200+
w := admission.WithCustomDefaulter(blder.mgr.GetScheme(), blder.apiType, defaulter, blder.customDefaulterOpts...)
198201
if blder.recoverPanic != nil {
199202
w = w.WithRecoverPanic(*blder.recoverPanic)
200203
}

pkg/webhook/admission/defaulter_custom.go

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,56 @@ import (
2121
"encoding/json"
2222
"errors"
2323
"net/http"
24+
"slices"
2425

26+
"gomodules.xyz/jsonpatch/v2"
2527
admissionv1 "k8s.io/api/admission/v1"
2628
apierrors "k8s.io/apimachinery/pkg/api/errors"
2729
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2830
"k8s.io/apimachinery/pkg/runtime"
31+
"k8s.io/apimachinery/pkg/util/sets"
2932
)
3033

3134
// CustomDefaulter defines functions for setting defaults on resources.
3235
type CustomDefaulter interface {
3336
Default(ctx context.Context, obj runtime.Object) error
3437
}
3538

39+
type defaulterOptions struct {
40+
removeUnknownOrOmitableFields bool
41+
}
42+
43+
// DefaulterOption defines the type of a CustomDefaulter's option
44+
type DefaulterOption func(*defaulterOptions)
45+
46+
// DefaulterRemoveUnknownOrOmitableFields makes the defaulter prune fields that are in the json object retrieved by the
47+
// webhook but not in the local go type json representation. This happens for example when the CRD in the apiserver has
48+
// fields that our go type doesn't know about, because it's outdated, or the field has a zero value and is `omitempty`.
49+
func DefaulterRemoveUnknownOrOmitableFields(o *defaulterOptions) {
50+
o.removeUnknownOrOmitableFields = true
51+
}
52+
3653
// WithCustomDefaulter creates a new Webhook for a CustomDefaulter interface.
37-
func WithCustomDefaulter(scheme *runtime.Scheme, obj runtime.Object, defaulter CustomDefaulter) *Webhook {
54+
func WithCustomDefaulter(scheme *runtime.Scheme, obj runtime.Object, defaulter CustomDefaulter, opts ...DefaulterOption) *Webhook {
55+
options := &defaulterOptions{}
56+
for _, o := range opts {
57+
o(options)
58+
}
3859
return &Webhook{
39-
Handler: &defaulterForType{object: obj, defaulter: defaulter, decoder: NewDecoder(scheme)},
60+
Handler: &defaulterForType{
61+
object: obj,
62+
defaulter: defaulter,
63+
decoder: NewDecoder(scheme),
64+
removeUnknownOrOmitableFields: options.removeUnknownOrOmitableFields,
65+
},
4066
}
4167
}
4268

4369
type defaulterForType struct {
44-
defaulter CustomDefaulter
45-
object runtime.Object
46-
decoder Decoder
70+
defaulter CustomDefaulter
71+
object runtime.Object
72+
decoder Decoder
73+
removeUnknownOrOmitableFields bool
4774
}
4875

4976
// Handle handles admission requests.
@@ -76,6 +103,12 @@ func (h *defaulterForType) Handle(ctx context.Context, req Request) Response {
76103
return Errored(http.StatusBadRequest, err)
77104
}
78105

106+
// Keep a copy of the object if needed
107+
var originalObj runtime.Object
108+
if !h.removeUnknownOrOmitableFields {
109+
originalObj = obj.DeepCopyObject()
110+
}
111+
79112
// Default the object
80113
if err := h.defaulter.Default(ctx, obj); err != nil {
81114
var apiStatus apierrors.APIStatus
@@ -90,5 +123,43 @@ func (h *defaulterForType) Handle(ctx context.Context, req Request) Response {
90123
if err != nil {
91124
return Errored(http.StatusInternalServerError, err)
92125
}
93-
return PatchResponseFromRaw(req.Object.Raw, marshalled)
126+
127+
handlerResponse := PatchResponseFromRaw(req.Object.Raw, marshalled)
128+
if !h.removeUnknownOrOmitableFields {
129+
handlerResponse = h.dropSchemeRemovals(handlerResponse, originalObj, req.Object.Raw)
130+
}
131+
return handlerResponse
132+
}
133+
134+
func (h *defaulterForType) dropSchemeRemovals(r Response, original runtime.Object, raw []byte) Response {
135+
const opRemove = "remove"
136+
if !r.Allowed || r.PatchType == nil {
137+
return r
138+
}
139+
140+
// If we don't have removals in the patch.
141+
if !slices.ContainsFunc(r.Patches, func(o jsonpatch.JsonPatchOperation) bool { return o.Operation == opRemove }) {
142+
return r
143+
}
144+
145+
// Get the raw to original patch
146+
marshalledOriginal, err := json.Marshal(original)
147+
if err != nil {
148+
return Errored(http.StatusInternalServerError, err)
149+
}
150+
151+
patchOriginal, err := jsonpatch.CreatePatch(raw, marshalledOriginal)
152+
if err != nil {
153+
return Errored(http.StatusInternalServerError, err)
154+
}
155+
removedByScheme := sets.New(slices.DeleteFunc(patchOriginal, func(p jsonpatch.JsonPatchOperation) bool { return p.Operation != opRemove })...)
156+
157+
r.Patches = slices.DeleteFunc(r.Patches, func(p jsonpatch.JsonPatchOperation) bool {
158+
return removedByScheme.Has(p)
159+
})
160+
161+
if len(r.Patches) == 0 {
162+
r.PatchType = nil
163+
}
164+
return r
94165
}

pkg/webhook/admission/defaulter_custom_test.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020

2121
. "github.com/onsi/ginkgo/v2"
2222
. "github.com/onsi/gomega"
23+
"gomodules.xyz/jsonpatch/v2"
2324

2425
admissionv1 "k8s.io/api/admission/v1"
2526
"k8s.io/apimachinery/pkg/runtime"
@@ -28,6 +29,66 @@ import (
2829

2930
var _ = Describe("Defaulter Handler", func() {
3031

32+
It("should remove unknown fields when DefaulterRemoveUnknownFields is passed", func() {
33+
obj := &TestDefaulter{}
34+
handler := WithCustomDefaulter(admissionScheme, obj, &TestCustomDefaulter{}, DefaulterRemoveUnknownOrOmitableFields)
35+
36+
resp := handler.Handle(context.TODO(), Request{
37+
AdmissionRequest: admissionv1.AdmissionRequest{
38+
Operation: admissionv1.Create,
39+
Object: runtime.RawExtension{
40+
Raw: []byte(`{"newField":"foo", "totalReplicas":5}`),
41+
},
42+
},
43+
})
44+
Expect(resp.Allowed).Should(BeTrue())
45+
Expect(resp.Patches).To(HaveLen(3))
46+
Expect(resp.Patches).To(ContainElements(
47+
jsonpatch.JsonPatchOperation{
48+
Operation: "add",
49+
Path: "/replica",
50+
Value: 2.0,
51+
},
52+
jsonpatch.JsonPatchOperation{
53+
Operation: "remove",
54+
Path: "/newField",
55+
},
56+
jsonpatch.JsonPatchOperation{
57+
Operation: "remove",
58+
Path: "/totalReplicas",
59+
},
60+
))
61+
Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK)))
62+
})
63+
64+
It("should preserve unknown fields by default", func() {
65+
obj := &TestDefaulter{}
66+
handler := WithCustomDefaulter(admissionScheme, obj, &TestCustomDefaulter{})
67+
68+
resp := handler.Handle(context.TODO(), Request{
69+
AdmissionRequest: admissionv1.AdmissionRequest{
70+
Operation: admissionv1.Create,
71+
Object: runtime.RawExtension{
72+
Raw: []byte(`{"newField":"foo", "totalReplicas":5}`),
73+
},
74+
},
75+
})
76+
Expect(resp.Allowed).Should(BeTrue())
77+
Expect(resp.Patches).To(HaveLen(2))
78+
Expect(resp.Patches).To(ContainElements(
79+
jsonpatch.JsonPatchOperation{
80+
Operation: "add",
81+
Path: "/replica",
82+
Value: 2.0,
83+
},
84+
jsonpatch.JsonPatchOperation{
85+
Operation: "remove",
86+
Path: "/totalReplicas",
87+
},
88+
))
89+
Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK)))
90+
})
91+
3192
It("should return ok if received delete verb in defaulter handler", func() {
3293
obj := &TestDefaulter{}
3394
handler := WithCustomDefaulter(admissionScheme, obj, &TestCustomDefaulter{})
@@ -48,15 +109,17 @@ var _ = Describe("Defaulter Handler", func() {
48109
var _ runtime.Object = &TestDefaulter{}
49110

50111
type TestDefaulter struct {
51-
Replica int `json:"replica,omitempty"`
112+
Replica int `json:"replica,omitempty"`
113+
TotalReplicas int `json:"totalReplicas,omitempty"`
52114
}
53115

54116
var testDefaulterGVK = schema.GroupVersionKind{Group: "foo.test.org", Version: "v1", Kind: "TestDefaulter"}
55117

56118
func (d *TestDefaulter) GetObjectKind() schema.ObjectKind { return d }
57119
func (d *TestDefaulter) DeepCopyObject() runtime.Object {
58120
return &TestDefaulter{
59-
Replica: d.Replica,
121+
Replica: d.Replica,
122+
TotalReplicas: d.TotalReplicas,
60123
}
61124
}
62125

@@ -81,5 +144,6 @@ func (d *TestCustomDefaulter) Default(ctx context.Context, obj runtime.Object) e
81144
if o.Replica < 2 {
82145
o.Replica = 2
83146
}
147+
o.TotalReplicas = 0
84148
return nil
85149
}

0 commit comments

Comments
 (0)