Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 Fix custom defaulter: avoid deleting unknown fields #2931

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions pkg/webhook/admission/defaulter_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,24 +71,32 @@ func (h *defaulterForType) Handle(ctx context.Context, req Request) Response {
ctx = NewContextWithRequest(ctx, req)

// Get the object in the request
obj := h.object.DeepCopyObject()
if err := h.decoder.Decode(req, obj); err != nil {
original := h.object.DeepCopyObject()
if err := h.decoder.Decode(req, original); err != nil {
return Errored(http.StatusBadRequest, err)
}

// Default the object
if err := h.defaulter.Default(ctx, obj); err != nil {
updated := original.DeepCopyObject()
if err := h.defaulter.Default(ctx, updated); err != nil {
var apiStatus apierrors.APIStatus
if errors.As(err, &apiStatus) {
return validationResponseFromStatus(false, apiStatus.Status())
}
return Denied(err.Error())
}

// Create the patch
marshalled, err := json.Marshal(obj)
// Create the patch.
// We need to decode and marshall the original because the type registered in the
// decoder might not match the latest version of the API.
// Creating a diff from the raw object might cause new fields to be dropped.
marshalledOriginal, err := json.Marshal(original)
Copy link
Member

@sbueringer sbueringer Aug 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can lead to invalid patches. Now we are calculating patches based on how original looks like after unmarshal + marshal. But the apiserver will have to apply the patches on the original JSON object

(We had problems like this when implementing our own patch calculation like it is suggested here on this PR)

Copy link
Member

@sbueringer sbueringer Aug 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example: Imagine an API type that through (probably incorrect) omitempty / pointer/non-pointer fields will add additional fields to marshalledOriginal. This could be e.g.:

  1. a scalar field with an empty value or
  2. an object field with {}.

Now the resulting patches will:

  1. if the scalar field is defaulted: generate a replace instead of an add for the field
  2. if a field should be added through defaulting to this object field: it will just add the field and miss a patch to add the parent object

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point, but both the existing and the proposed approaches are buggy.

Generally, the apiserver would have to go through the same unmarshal + marshal process, then the "raw" contents should not differ. A problem like the one you mention could arise when the webhook uses newer APIs than the API server has registered.

The problem I present arises when the API server has newer APIs than the webhook.

Not sure which can be more common.

Copy link
Member

@sbueringer sbueringer Aug 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, the apiserver would have to go through the same unmarshal + marshal process, then the "raw" contents should not differ

For CRDs the apiserver doesn't even have the Go API types :)

Yeah it's definitely a tricky problem. I just think that if we now switch from one buggy version to another we will break a lot of people that currently have (somewhat) working implementations with controller-runtime

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please look at #2932?

if err != nil {
return Errored(http.StatusInternalServerError, err)
}
return PatchResponseFromRaw(req.Object.Raw, marshalled)
marshalledUpdated, err := json.Marshal(updated)
if err != nil {
return Errored(http.StatusInternalServerError, err)
}
return PatchResponseFromRaw(marshalledOriginal, marshalledUpdated)
}
87 changes: 87 additions & 0 deletions pkg/webhook/admission/defaulter_custom_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package admission

import (
"context"
"net/http"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gomodules.xyz/jsonpatch/v2"

admissionv1 "k8s.io/api/admission/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)

var _ = Describe("CustomDefaulter Handler", func() {

It("should should not lose unknown fields", func() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
It("should should not lose unknown fields", func() {
It("should not lose unknown fields", func() {

:)

obj := &TestObject{}
handler := WithCustomDefaulter(admissionScheme, obj, &TestCustomDefaulter{})

resp := handler.Handle(context.TODO(), Request{
AdmissionRequest: admissionv1.AdmissionRequest{
Operation: admissionv1.Create,
Object: runtime.RawExtension{
Raw: []byte(`{"newField":"foo"}`),
},
},
})
Expect(resp.Allowed).Should(BeTrue())
Expect(resp.Patches).To(Equal([]jsonpatch.JsonPatchOperation{{
Operation: "add",
Path: "/replica",
Value: 2.0,
}}))
Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK)))
})

It("should return ok if received delete verb in defaulter handler", func() {
obj := &TestObject{}
handler := WithCustomDefaulter(admissionScheme, obj, &TestCustomDefaulter{})

resp := handler.Handle(context.TODO(), Request{
AdmissionRequest: admissionv1.AdmissionRequest{
Operation: admissionv1.Delete,
OldObject: runtime.RawExtension{
Raw: []byte("{}"),
},
},
})
Expect(resp.Allowed).Should(BeTrue())
Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK)))
})

})

type TestCustomDefaulter struct{}

func (d *TestCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error {
tObj := obj.(*TestObject)
if tObj.Replica < 2 {
tObj.Replica = 2
}
return nil
}

type TestObject struct {
Replica int `json:"replica,omitempty"`
}

func (o *TestObject) GetObjectKind() schema.ObjectKind { return o }
func (o *TestObject) DeepCopyObject() runtime.Object {
return &TestObject{
Replica: o.Replica,
}
}

func (o *TestObject) GroupVersionKind() schema.GroupVersionKind {
return testDefaulterGVK
}

func (o *TestObject) SetGroupVersionKind(gvk schema.GroupVersionKind) {}

type TestObjectList struct{}

func (*TestObjectList) GetObjectKind() schema.ObjectKind { return nil }
func (*TestObjectList) DeepCopyObject() runtime.Object { return nil }
Loading