Skip to content

Commit d83c801

Browse files
committed
Update fake client deletionTimestamp behavior
The current fake client methods diverge from the behavior of real k8s. This results in scenarios where an object can be created or modified to have a deletionTimestamp and no finalizers, with a delay in garbage collection, causing inaccurate or ambiguous behavior in test cases that use the fake client. Bring fake client behavior closer in line with real k8s behavior by modifying the following: - Update() and Patch() will reject changes that set a previously-unset deletionTimestamp - Create() will ignore a deletionTimestamp on an object - Build() will error if initialized with an object that has a deletionTimestamp but no finalizers; but will accept a deletionTimestamp if it has finalizers. Signed-off-by: Leah Leshchinsky <[email protected]>
1 parent 0ef0753 commit d83c801

File tree

2 files changed

+297
-10
lines changed

2 files changed

+297
-10
lines changed

pkg/client/fake/client.go

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ import (
2727
"strconv"
2828
"strings"
2929
"sync"
30+
"time"
3031

32+
jsonpatch "github.com/evanphx/json-patch"
3133
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
3234

3335
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -38,8 +40,10 @@ import (
3840
"k8s.io/apimachinery/pkg/labels"
3941
"k8s.io/apimachinery/pkg/runtime"
4042
"k8s.io/apimachinery/pkg/runtime/schema"
43+
"k8s.io/apimachinery/pkg/types"
4144
utilrand "k8s.io/apimachinery/pkg/util/rand"
4245
"k8s.io/apimachinery/pkg/util/sets"
46+
"k8s.io/apimachinery/pkg/util/strategicpatch"
4347
"k8s.io/apimachinery/pkg/util/validation/field"
4448
"k8s.io/apimachinery/pkg/watch"
4549
"k8s.io/client-go/kubernetes/scheme"
@@ -282,6 +286,9 @@ func (t versionedTracker) Add(obj runtime.Object) error {
282286
if err != nil {
283287
return fmt.Errorf("failed to get accessor for object: %w", err)
284288
}
289+
if accessor.GetDeletionTimestamp() != nil && len(accessor.GetFinalizers()) == 0 {
290+
return fmt.Errorf("Refusing to init obj %s with metadata.deletionTimestamp but no finalizers.", accessor.GetName())
291+
}
285292
if accessor.GetResourceVersion() == "" {
286293
// We use a "magic" value of 999 here because this field
287294
// is parsed as uint and and 0 is already used in Update.
@@ -365,10 +372,10 @@ func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Ob
365372
if bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).Patch")) {
366373
isStatus = true
367374
}
368-
return t.update(gvr, obj, ns, isStatus)
375+
return t.update(gvr, obj, ns, isStatus, false)
369376
}
370377

371-
func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus bool) error {
378+
func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus bool, mutable bool) error {
372379
accessor, err := meta.Accessor(obj)
373380
if err != nil {
374381
return fmt.Errorf("failed to get accessor for object: %w", err)
@@ -435,9 +442,15 @@ func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Ob
435442
}
436443
intResourceVersion++
437444
accessor.SetResourceVersion(strconv.FormatUint(intResourceVersion, 10))
438-
if !accessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 {
445+
446+
if !oldAccessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 {
439447
return t.ObjectTracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName())
440448
}
449+
450+
if oldAccessor.GetDeletionTimestamp() != accessor.GetDeletionTimestamp() && !mutable {
451+
452+
return fmt.Errorf("Error: Unable to edit %s: metadata.deletionTimestamp field is immutable", accessor.GetName())
453+
}
441454
obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj)
442455
if err != nil {
443456
return err
@@ -664,6 +677,10 @@ func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...clie
664677
}
665678
accessor.SetName(fmt.Sprintf("%s%s", base, utilrand.String(randomLength)))
666679
}
680+
// Ignore attempts to set deletion timestamp
681+
if !accessor.GetDeletionTimestamp().IsZero() {
682+
accessor.SetDeletionTimestamp(nil)
683+
}
667684

668685
return c.tracker.Create(gvr, obj, accessor.GetNamespace())
669686
}
@@ -775,7 +792,7 @@ func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.Upd
775792
if err != nil {
776793
return err
777794
}
778-
return c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus)
795+
return c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus, false)
779796
}
780797

781798
func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
@@ -810,8 +827,36 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
810827
return err
811828
}
812829

830+
o, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName())
831+
if err != nil {
832+
return err
833+
}
834+
oldObj, err := meta.Accessor(o)
835+
if err != nil {
836+
return err
837+
}
838+
839+
// Apply patch without updating object.
840+
// To remain in accordance with the behavior of k8s api behavior,
841+
// a patch must not allow for changes to the deletionTimestamp of an object.
842+
// The reaction() function applies the patch to the object and calls Update(),
843+
// whereas dryPatch() replicates this behavior but disregards the call to Update().
844+
// This ensures that the patch may be rejected if a deletionTimestamp is modified, prior
845+
// to updating the object.
846+
action := testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data)
847+
o, err = dryPatch(action, c.tracker)
848+
if err != nil {
849+
return err
850+
}
851+
newObj, err := meta.Accessor(o)
852+
853+
// Validate that deletionTimestamp has not been changed
854+
if !validTimestampDifference(newObj, oldObj) {
855+
return fmt.Errorf("Rejected patch, metadata.deletionTimestamp immutable.")
856+
}
857+
813858
reaction := testing.ObjectReaction(c.tracker)
814-
handled, o, err := reaction(testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data))
859+
handled, o, err := reaction(action)
815860
if err != nil {
816861
return err
817862
}
@@ -835,6 +880,84 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
835880
return err
836881
}
837882

883+
// Applying a patch results in a deletionTimestamp that is truncated to the nearest second.
884+
// Check that the diff between a new and old deletion timestamp is within a reasonable threshold
885+
// to be considered unchanged.
886+
func validTimestampDifference(newObj metav1.Object, obj metav1.Object) bool {
887+
888+
new_t := newObj.GetDeletionTimestamp()
889+
old_t := obj.GetDeletionTimestamp()
890+
891+
if new_t == nil || old_t == nil {
892+
return new_t == old_t
893+
}
894+
return newObj.GetDeletionTimestamp().Sub(obj.GetDeletionTimestamp().Time).Abs() < time.Second
895+
}
896+
897+
// The behavior of applying the patch is pulled out into dryPatch(),
898+
// which applies the patch and returns an object, but does not Update() the object.
899+
// This function returns a patched runtime object that may then be validated before a call to Update() is executed.
900+
// This results in some code duplication, but was found to be a cleaner alternative than unmarshalling the data
901+
// or updating the k8s client-go methods directly.
902+
func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) {
903+
904+
ns := action.GetNamespace()
905+
gvr := action.GetResource()
906+
907+
obj, err := tracker.Get(gvr, ns, action.GetName())
908+
if err != nil {
909+
return nil, err
910+
}
911+
912+
old, err := json.Marshal(obj)
913+
if err != nil {
914+
return nil, err
915+
}
916+
917+
// reset the object in preparation to unmarshal, since unmarshal does not guarantee that fields
918+
// in obj that are removed by patch are cleared
919+
value := reflect.ValueOf(obj)
920+
value.Elem().Set(reflect.New(value.Type().Elem()).Elem())
921+
922+
switch action.GetPatchType() {
923+
case types.JSONPatchType:
924+
patch, err := jsonpatch.DecodePatch(action.GetPatch())
925+
if err != nil {
926+
return nil, err
927+
}
928+
modified, err := patch.Apply(old)
929+
if err != nil {
930+
return nil, err
931+
}
932+
933+
if err = json.Unmarshal(modified, obj); err != nil {
934+
return nil, err
935+
}
936+
case types.MergePatchType:
937+
modified, err := jsonpatch.MergePatch(old, action.GetPatch())
938+
if err != nil {
939+
return nil, err
940+
}
941+
942+
if err := json.Unmarshal(modified, obj); err != nil {
943+
return nil, err
944+
}
945+
case types.StrategicMergePatchType, types.ApplyPatchType:
946+
mergedByte, err := strategicpatch.StrategicMergePatch(old, action.GetPatch(), obj)
947+
if err != nil {
948+
return nil, err
949+
}
950+
if err = json.Unmarshal(mergedByte, obj); err != nil {
951+
return nil, err
952+
}
953+
default:
954+
return nil, fmt.Errorf("PatchType is not supported")
955+
}
956+
957+
return obj, nil
958+
959+
}
960+
838961
func copyNonStatusFrom(old, new runtime.Object) error {
839962
newClientObject, ok := new.(client.Object)
840963
if !ok {
@@ -942,7 +1065,9 @@ func (c *fakeClient) deleteObject(gvr schema.GroupVersionResource, accessor meta
9421065
if len(oldAccessor.GetFinalizers()) > 0 {
9431066
now := metav1.Now()
9441067
oldAccessor.SetDeletionTimestamp(&now)
945-
return c.tracker.Update(gvr, old, accessor.GetNamespace())
1068+
// Call update directly with mutability parameter set to true to allow
1069+
// changes to deletionTimestamp
1070+
return c.tracker.update(gvr, old, accessor.GetNamespace(), false, true)
9461071
}
9471072
}
9481073
}

0 commit comments

Comments
 (0)