Skip to content

Commit f2f92cb

Browse files
fix ssa patch by applying all managed fields at once
Signed-off-by: TehilaTheStudent <tehila14916@gmail.com>
1 parent 5a8380a commit f2f92cb

File tree

2 files changed

+237
-1
lines changed

2 files changed

+237
-1
lines changed

pkg/reconciler/managed/api.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ import (
2525
"github.com/google/go-cmp/cmp/cmpopts"
2626
corev1 "k8s.io/api/core/v1"
2727
kerrors "k8s.io/apimachinery/pkg/api/errors"
28+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2829
"k8s.io/apimachinery/pkg/runtime"
2930
"k8s.io/apimachinery/pkg/runtime/schema"
3031
"k8s.io/apimachinery/pkg/types"
32+
"k8s.io/apimachinery/pkg/util/managedfields"
3133
"k8s.io/client-go/util/retry"
3234
"sigs.k8s.io/controller-runtime/pkg/client"
35+
"sigs.k8s.io/structured-merge-diff/v6/typed"
3336

3437
"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
3538
"github.com/crossplane/crossplane-runtime/v2/pkg/meta"
@@ -53,6 +56,8 @@ const (
5356
errUpdateManagedStatus = "cannot update managed resource status"
5457
errResolveReferences = "cannot resolve references"
5558
errUpdateCriticalAnnotations = "cannot update critical annotations"
59+
errMarshalManagedFields = "cannot marshal previously managed fields"
60+
errMergePatches = "cannot merge patches"
5661
)
5762

5863
// NameAsExternalName writes the name of the managed resource to
@@ -228,8 +233,47 @@ func prepareJSONMerge(existing, resolved runtime.Object) ([]byte, error) {
228233
}
229234

230235
patch, err := jsonpatch.CreateMergePatch(eBuff, rBuff)
236+
if err != nil {
237+
return nil, errors.Wrap(err, errPreparePatch)
238+
}
239+
240+
// Merge with previously managed fields to maintain ownership.
241+
return mergeWithManagedFields(patch, resolved, fieldOwnerAPISimpleRefResolver)
242+
}
243+
244+
// mergeWithManagedFields merges the patch with fields previously managed by the
245+
// specified field manager. This ensures that SSA patches include all fields the
246+
// manager owns, preventing the API server from interpreting missing fields as
247+
// intentional deletions which would cause an infinite reconciliation loop.
248+
func mergeWithManagedFields(patch []byte, obj runtime.Object, fieldManager string) ([]byte, error) {
249+
// ExtractInto requires an unstructured representation of the object.
250+
u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
251+
if err != nil {
252+
return patch, nil //nolint:nilerr // Conversion errors are non-fatal; return the original patch.
253+
}
254+
255+
managed := &unstructured.Unstructured{}
256+
if err := managedfields.ExtractInto(&unstructured.Unstructured{Object: u}, typed.DeducedParseableType, fieldManager, managed, ""); err != nil {
257+
// ExtractInto can fail for edge cases like managedFields references fields that don't exist on the observed object.
258+
// See https://github.com/crossplane-contrib/provider-kubernetes/issues/420
259+
return patch, nil //nolint:nilerr // Extraction errors are non-fatal; return the original patch.
260+
}
261+
262+
if len(managed.Object) == 0 {
263+
return patch, nil
264+
}
265+
266+
b, err := json.Marshal(managed.Object)
267+
if err != nil {
268+
return nil, errors.Wrap(err, errMarshalManagedFields)
269+
}
270+
271+
merged, err := jsonpatch.MergePatch(b, patch)
272+
if err != nil {
273+
return nil, errors.Wrap(err, errMergePatches)
274+
}
231275

232-
return patch, errors.Wrap(err, errPreparePatch)
276+
return merged, nil
233277
}
234278

235279
// ResolveReferences of the supplied managed resource by calling its

pkg/reconciler/managed/api_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/google/go-cmp/cmp"
2424
kerrors "k8s.io/apimachinery/pkg/api/errors"
2525
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2627
"k8s.io/apimachinery/pkg/runtime"
2728
"k8s.io/apimachinery/pkg/runtime/schema"
2829
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -503,6 +504,39 @@ func TestPrepareJSONMerge(t *testing.T) {
503504
err error
504505
}
505506

507+
newManagedFieldsObj := func() *unstructured.Unstructured {
508+
u := &unstructured.Unstructured{}
509+
u.SetGroupVersionKind(schema.GroupVersionKind{
510+
Group: "example.com",
511+
Version: "v1",
512+
Kind: "TestResource",
513+
})
514+
u.SetName("test-resource")
515+
_ = unstructured.SetNestedField(u.Object, "new-ref-id", "spec", "forProvider", "someRefId")
516+
u.SetManagedFields([]metav1.ManagedFieldsEntry{
517+
{
518+
Manager: fieldOwnerAPISimpleRefResolver,
519+
Operation: metav1.ManagedFieldsOperationApply,
520+
FieldsType: "FieldsV1",
521+
FieldsV1: &metav1.FieldsV1{
522+
Raw: []byte(`{"f:spec":{"f:forProvider":{"f:otherRefId":{}}}}`),
523+
},
524+
},
525+
})
526+
return u
527+
}
528+
529+
withoutManagedFields := func() *unstructured.Unstructured {
530+
u := &unstructured.Unstructured{}
531+
u.SetGroupVersionKind(schema.GroupVersionKind{
532+
Group: "example.com",
533+
Version: "v1",
534+
Kind: "TestResource",
535+
})
536+
u.SetName("test-resource")
537+
return u
538+
}
539+
506540
cases := map[string]struct {
507541
reason string
508542
args args
@@ -522,6 +556,43 @@ func TestPrepareJSONMerge(t *testing.T) {
522556
patch: `{"name":"resolved"}`,
523557
},
524558
},
559+
"PatchWithNoChanges": {
560+
reason: "Should return empty patch when existing and resolved are the same.",
561+
args: args{
562+
existing: &fake.LegacyManaged{
563+
ObjectMeta: metav1.ObjectMeta{
564+
Name: "same",
565+
},
566+
},
567+
resolved: &fake.LegacyManaged{
568+
ObjectMeta: metav1.ObjectMeta{
569+
Name: "same",
570+
},
571+
},
572+
},
573+
want: want{
574+
patch: `{}`,
575+
},
576+
},
577+
"PatchWithManagedFieldsMerged": {
578+
// Verifies that managed fields referencing fields not in the current patch
579+
// are still included in the final patch to maintain SSA field ownership.
580+
// Note: The expected patch includes managedFields metadata because existing
581+
// has none while resolved does - this is a test artifact. In production,
582+
// both objects would have identical managedFields so the diff wouldn't include them.
583+
reason: "Should merge previously managed fields into the patch to maintain SSA field ownership.",
584+
args: args{
585+
existing: withoutManagedFields(),
586+
resolved: newManagedFieldsObj(),
587+
},
588+
want: want{
589+
patch: `{"apiVersion":"example.com/v1","kind":"TestResource",` +
590+
`"metadata":{"managedFields":[{"fieldsType":"FieldsV1",` +
591+
`"fieldsV1":{"f:spec":{"f:forProvider":{"f:otherRefId":{}}}},` +
592+
`"manager":"managed.crossplane.io/api-simple-reference-resolver",` +
593+
`"operation":"Apply"}]},"spec":{"forProvider":{"someRefId":"new-ref-id"}}}`,
594+
},
595+
},
525596
}
526597

527598
for name, tc := range cases {
@@ -538,6 +609,127 @@ func TestPrepareJSONMerge(t *testing.T) {
538609
}
539610
}
540611

612+
func TestMergeWithManagedFields(t *testing.T) {
613+
type args struct {
614+
patch []byte
615+
obj runtime.Object
616+
fieldManager string
617+
}
618+
619+
type want struct {
620+
patch string
621+
err error
622+
}
623+
624+
withManagedFields := func() *unstructured.Unstructured {
625+
u := &unstructured.Unstructured{}
626+
u.SetGroupVersionKind(schema.GroupVersionKind{
627+
Group: "example.com",
628+
Version: "v1",
629+
Kind: "TestResource",
630+
})
631+
u.SetName("test-resource")
632+
_ = unstructured.SetNestedField(u.Object, "old-ref-value", "spec", "forProvider", "someRefId")
633+
u.SetManagedFields([]metav1.ManagedFieldsEntry{
634+
{
635+
Manager: fieldOwnerAPISimpleRefResolver,
636+
Operation: metav1.ManagedFieldsOperationApply,
637+
FieldsType: "FieldsV1",
638+
FieldsV1: &metav1.FieldsV1{
639+
Raw: []byte(`{"f:spec":{"f:forProvider":{"f:someRefId":{}}}}`),
640+
},
641+
},
642+
})
643+
return u
644+
}
645+
646+
withDifferentManager := func() *unstructured.Unstructured {
647+
u := &unstructured.Unstructured{}
648+
u.SetGroupVersionKind(schema.GroupVersionKind{
649+
Group: "example.com",
650+
Version: "v1",
651+
Kind: "TestResource",
652+
})
653+
u.SetName("test-resource")
654+
_ = unstructured.SetNestedField(u.Object, "other-ref-value", "spec", "forProvider", "otherRefId")
655+
u.SetManagedFields([]metav1.ManagedFieldsEntry{
656+
{
657+
Manager: "different-manager",
658+
Operation: metav1.ManagedFieldsOperationApply,
659+
FieldsType: "FieldsV1",
660+
FieldsV1: &metav1.FieldsV1{
661+
Raw: []byte(`{"f:spec":{"f:forProvider":{"f:otherRefId":{}}}}`),
662+
},
663+
},
664+
})
665+
return u
666+
}
667+
668+
cases := map[string]struct {
669+
reason string
670+
args args
671+
want want
672+
}{
673+
"NoManagedFields": {
674+
reason: "Should return the original patch when there are no managed fields.",
675+
args: args{
676+
patch: []byte(`{"metadata":{"name":"resolved"}}`),
677+
obj: &fake.LegacyManaged{},
678+
fieldManager: fieldOwnerAPISimpleRefResolver,
679+
},
680+
want: want{
681+
patch: `{"metadata":{"name":"resolved"}}`,
682+
},
683+
},
684+
"EmptyPatch": {
685+
reason: "Should return empty patch when patch is empty and no managed fields.",
686+
args: args{
687+
patch: []byte(`{}`),
688+
obj: &fake.LegacyManaged{},
689+
fieldManager: fieldOwnerAPISimpleRefResolver,
690+
},
691+
want: want{
692+
patch: `{}`,
693+
},
694+
},
695+
"WithManagedFields": {
696+
reason: "Should merge previously managed fields into the patch to maintain field ownership.",
697+
args: args{
698+
patch: []byte(`{"spec":{"forProvider":{"newRefId":"new-value"}}}`),
699+
obj: withManagedFields(),
700+
fieldManager: fieldOwnerAPISimpleRefResolver,
701+
},
702+
want: want{
703+
patch: `{"apiVersion":"example.com/v1","kind":"TestResource","spec":{"forProvider":{"newRefId":"new-value","someRefId":"old-ref-value"}}}`,
704+
},
705+
},
706+
"DifferentFieldManager": {
707+
reason: "Should not include managed fields from a different field manager.",
708+
args: args{
709+
patch: []byte(`{"metadata":{"name":"resolved"}}`),
710+
obj: withDifferentManager(),
711+
fieldManager: fieldOwnerAPISimpleRefResolver,
712+
},
713+
want: want{
714+
patch: `{"metadata":{"name":"resolved"}}`,
715+
},
716+
},
717+
}
718+
719+
for name, tc := range cases {
720+
t.Run(name, func(t *testing.T) {
721+
patch, err := mergeWithManagedFields(tc.args.patch, tc.args.obj, tc.args.fieldManager)
722+
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
723+
t.Errorf("\n%s\nmergeWithManagedFields(...): -wantErr, +gotErr:\n%s", tc.reason, diff)
724+
}
725+
726+
if diff := cmp.Diff(tc.want.patch, string(patch)); diff != "" {
727+
t.Errorf("\n%s\nmergeWithManagedFields(...): -want, +got:\n%s", tc.reason, diff)
728+
}
729+
})
730+
}
731+
}
732+
541733
func TestRetryingCriticalAnnotationUpdater(t *testing.T) {
542734
errBoom := errors.New("boom")
543735

0 commit comments

Comments
 (0)