Skip to content

Commit c28b017

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 c28b017

File tree

3 files changed

+318
-11
lines changed

3 files changed

+318
-11
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module sigs.k8s.io/controller-runtime
33
go 1.20
44

55
require (
6+
github.com/evanphx/json-patch v4.12.0+incompatible
67
github.com/evanphx/json-patch/v5 v5.6.0
78
github.com/fsnotify/fsnotify v1.6.0
89
github.com/go-logr/logr v1.2.4
@@ -32,7 +33,6 @@ require (
3233
github.com/cespare/xxhash/v2 v2.2.0 // indirect
3334
github.com/davecgh/go-spew v1.1.1 // indirect
3435
github.com/emicklei/go-restful/v3 v3.9.0 // indirect
35-
github.com/evanphx/json-patch v4.12.0+incompatible // indirect
3636
github.com/go-openapi/jsonpointer v0.19.5 // indirect
3737
github.com/go-openapi/jsonreference v0.20.0 // indirect
3838
github.com/go-openapi/swag v0.19.14 // indirect

pkg/client/fake/client.go

Lines changed: 130 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, deleting 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,7 +442,12 @@ 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 !deleting && !validateTimestampDifference(accessor, oldAccessor) {
447+
return fmt.Errorf("error: Unable to edit %s: metadata.deletionTimestamp field is immutable", accessor.GetName())
448+
}
449+
450+
if !oldAccessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 {
439451
return t.ObjectTracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName())
440452
}
441453
obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj)
@@ -664,6 +676,10 @@ func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...clie
664676
}
665677
accessor.SetName(fmt.Sprintf("%s%s", base, utilrand.String(randomLength)))
666678
}
679+
// Ignore attempts to set deletion timestamp
680+
if !accessor.GetDeletionTimestamp().IsZero() {
681+
accessor.SetDeletionTimestamp(nil)
682+
}
667683

668684
return c.tracker.Create(gvr, obj, accessor.GetNamespace())
669685
}
@@ -775,7 +791,7 @@ func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.Upd
775791
if err != nil {
776792
return err
777793
}
778-
return c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus)
794+
return c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus, false)
779795
}
780796

781797
func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
@@ -810,8 +826,39 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
810826
return err
811827
}
812828

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

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

0 commit comments

Comments
 (0)