@@ -27,7 +27,10 @@ import (
2727 "strconv"
2828 "strings"
2929 "sync"
30+ "time"
3031
32+ // Using v4 to match upstream
33+ jsonpatch "github.com/evanphx/json-patch"
3134 "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
3235
3336 corev1 "k8s.io/api/core/v1"
@@ -41,8 +44,10 @@ import (
4144 "k8s.io/apimachinery/pkg/labels"
4245 "k8s.io/apimachinery/pkg/runtime"
4346 "k8s.io/apimachinery/pkg/runtime/schema"
47+ "k8s.io/apimachinery/pkg/types"
4448 utilrand "k8s.io/apimachinery/pkg/util/rand"
4549 "k8s.io/apimachinery/pkg/util/sets"
50+ "k8s.io/apimachinery/pkg/util/strategicpatch"
4651 "k8s.io/apimachinery/pkg/util/validation/field"
4752 "k8s.io/apimachinery/pkg/watch"
4853 "k8s.io/client-go/kubernetes/scheme"
@@ -285,6 +290,9 @@ func (t versionedTracker) Add(obj runtime.Object) error {
285290 if err != nil {
286291 return fmt .Errorf ("failed to get accessor for object: %w" , err )
287292 }
293+ if accessor .GetDeletionTimestamp () != nil && len (accessor .GetFinalizers ()) == 0 {
294+ return fmt .Errorf ("refusing to create obj %s with metadata.deletionTimestamp but no finalizers" , accessor .GetName ())
295+ }
288296 if accessor .GetResourceVersion () == "" {
289297 // We use a "magic" value of 999 here because this field
290298 // is parsed as uint and and 0 is already used in Update.
@@ -368,10 +376,10 @@ func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Ob
368376 if bytes .Contains (debug .Stack (), []byte ("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).Patch" )) {
369377 isStatus = true
370378 }
371- return t .update (gvr , obj , ns , isStatus )
379+ return t .update (gvr , obj , ns , isStatus , false )
372380}
373381
374- func (t versionedTracker ) update (gvr schema.GroupVersionResource , obj runtime.Object , ns string , isStatus bool ) error {
382+ func (t versionedTracker ) update (gvr schema.GroupVersionResource , obj runtime.Object , ns string , isStatus bool , deleting bool ) error {
375383 accessor , err := meta .Accessor (obj )
376384 if err != nil {
377385 return fmt .Errorf ("failed to get accessor for object: %w" , err )
@@ -438,6 +446,11 @@ func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Ob
438446 }
439447 intResourceVersion ++
440448 accessor .SetResourceVersion (strconv .FormatUint (intResourceVersion , 10 ))
449+
450+ if ! deleting && ! deletionTimestampEqual (accessor , oldAccessor ) {
451+ return fmt .Errorf ("error: Unable to edit %s: metadata.deletionTimestamp field is immutable" , accessor .GetName ())
452+ }
453+
441454 if ! accessor .GetDeletionTimestamp ().IsZero () && len (accessor .GetFinalizers ()) == 0 {
442455 return t .ObjectTracker .Delete (gvr , accessor .GetNamespace (), accessor .GetName ())
443456 }
@@ -667,6 +680,10 @@ func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...clie
667680 }
668681 accessor .SetName (fmt .Sprintf ("%s%s" , base , utilrand .String (randomLength )))
669682 }
683+ // Ignore attempts to set deletion timestamp
684+ if ! accessor .GetDeletionTimestamp ().IsZero () {
685+ accessor .SetDeletionTimestamp (nil )
686+ }
670687
671688 return c .tracker .Create (gvr , obj , accessor .GetNamespace ())
672689}
@@ -778,7 +795,7 @@ func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.Upd
778795 if err != nil {
779796 return err
780797 }
781- return c .tracker .update (gvr , obj , accessor .GetNamespace (), isStatus )
798+ return c .tracker .update (gvr , obj , accessor .GetNamespace (), isStatus , false )
782799}
783800
784801func (c * fakeClient ) Patch (ctx context.Context , obj client.Object , patch client.Patch , opts ... client.PatchOption ) error {
@@ -813,8 +830,39 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
813830 return err
814831 }
815832
833+ oldObj , err := c .tracker .Get (gvr , accessor .GetNamespace (), accessor .GetName ())
834+ if err != nil {
835+ return err
836+ }
837+ oldAccessor , err := meta .Accessor (oldObj )
838+ if err != nil {
839+ return err
840+ }
841+
842+ // Apply patch without updating object.
843+ // To remain in accordance with the behavior of k8s api behavior,
844+ // a patch must not allow for changes to the deletionTimestamp of an object.
845+ // The reaction() function applies the patch to the object and calls Update(),
846+ // whereas dryPatch() replicates this behavior but skips the call to Update().
847+ // This ensures that the patch may be rejected if a deletionTimestamp is modified, prior
848+ // to updating the object.
849+ action := testing .NewPatchAction (gvr , accessor .GetNamespace (), accessor .GetName (), patch .Type (), data )
850+ o , err := dryPatch (action , c .tracker )
851+ if err != nil {
852+ return err
853+ }
854+ newObj , err := meta .Accessor (o )
855+ if err != nil {
856+ return err
857+ }
858+
859+ // Validate that deletionTimestamp has not been changed
860+ if ! deletionTimestampEqual (newObj , oldAccessor ) {
861+ return fmt .Errorf ("rejected patch, metadata.deletionTimestamp immutable" )
862+ }
863+
816864 reaction := testing .ObjectReaction (c .tracker )
817- handled , o , err := reaction (testing . NewPatchAction ( gvr , accessor . GetNamespace (), accessor . GetName (), patch . Type (), data ) )
865+ handled , o , err := reaction (action )
818866 if err != nil {
819867 return err
820868 }
@@ -838,6 +886,81 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
838886 return err
839887}
840888
889+ // Applying a patch results in a deletionTimestamp that is truncated to the nearest second.
890+ // Check that the diff between a new and old deletion timestamp is within a reasonable threshold
891+ // to be considered unchanged.
892+ func deletionTimestampEqual (newObj metav1.Object , obj metav1.Object ) bool {
893+ newTime := newObj .GetDeletionTimestamp ()
894+ oldTime := obj .GetDeletionTimestamp ()
895+
896+ if newTime == nil || oldTime == nil {
897+ return newTime == oldTime
898+ }
899+ return newTime .Time .Sub (oldTime .Time ).Abs () < time .Second
900+ }
901+
902+ // The behavior of applying the patch is pulled out into dryPatch(),
903+ // which applies the patch and returns an object, but does not Update() the object.
904+ // This function returns a patched runtime object that may then be validated before a call to Update() is executed.
905+ // This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data
906+ // and easier than refactoring the k8s client-go method upstream.
907+ // Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194
908+ func dryPatch (action testing.PatchActionImpl , tracker testing.ObjectTracker ) (runtime.Object , error ) {
909+ ns := action .GetNamespace ()
910+ gvr := action .GetResource ()
911+
912+ obj , err := tracker .Get (gvr , ns , action .GetName ())
913+ if err != nil {
914+ return nil , err
915+ }
916+
917+ old , err := json .Marshal (obj )
918+ if err != nil {
919+ return nil , err
920+ }
921+
922+ // reset the object in preparation to unmarshal, since unmarshal does not guarantee that fields
923+ // in obj that are removed by patch are cleared
924+ value := reflect .ValueOf (obj )
925+ value .Elem ().Set (reflect .New (value .Type ().Elem ()).Elem ())
926+
927+ switch action .GetPatchType () {
928+ case types .JSONPatchType :
929+ patch , err := jsonpatch .DecodePatch (action .GetPatch ())
930+ if err != nil {
931+ return nil , err
932+ }
933+ modified , err := patch .Apply (old )
934+ if err != nil {
935+ return nil , err
936+ }
937+
938+ if err = json .Unmarshal (modified , obj ); err != nil {
939+ return nil , err
940+ }
941+ case types .MergePatchType :
942+ modified , err := jsonpatch .MergePatch (old , action .GetPatch ())
943+ if err != nil {
944+ return nil , err
945+ }
946+
947+ if err := json .Unmarshal (modified , obj ); err != nil {
948+ return nil , err
949+ }
950+ case types .StrategicMergePatchType , types .ApplyPatchType :
951+ mergedByte , err := strategicpatch .StrategicMergePatch (old , action .GetPatch (), obj )
952+ if err != nil {
953+ return nil , err
954+ }
955+ if err = json .Unmarshal (mergedByte , obj ); err != nil {
956+ return nil , err
957+ }
958+ default :
959+ return nil , fmt .Errorf ("PatchType is not supported" )
960+ }
961+ return obj , nil
962+ }
963+
841964func copyNonStatusFrom (old , new runtime.Object ) error {
842965 newClientObject , ok := new .(client.Object )
843966 if ! ok {
@@ -945,7 +1068,9 @@ func (c *fakeClient) deleteObject(gvr schema.GroupVersionResource, accessor meta
9451068 if len (oldAccessor .GetFinalizers ()) > 0 {
9461069 now := metav1 .Now ()
9471070 oldAccessor .SetDeletionTimestamp (& now )
948- return c .tracker .Update (gvr , old , accessor .GetNamespace ())
1071+ // Call update directly with mutability parameter set to true to allow
1072+ // changes to deletionTimestamp
1073+ return c .tracker .update (gvr , old , accessor .GetNamespace (), false , true )
9491074 }
9501075 }
9511076 }
0 commit comments