diff --git a/cmd/eno-reconciler/main.go b/cmd/eno-reconciler/main.go index dcf411d1..28282851 100644 --- a/cmd/eno-reconciler/main.go +++ b/cmd/eno-reconciler/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "strings" "time" "go.uber.org/zap" @@ -41,6 +42,7 @@ func run() error { namespaceCreationGracePeriod time.Duration namespaceCleanup bool enoBuildVersion string + migratingFieldManagers string mgrOpts = &manager.Options{ Rest: ctrl.GetConfigOrDie(), @@ -61,6 +63,7 @@ func run() error { flag.DurationVar(&namespaceCreationGracePeriod, "ns-creation-grace-period", time.Second, "A namespace is assumed to be missing if it doesn't exist once one of its resources has existed for this long") flag.BoolVar(&namespaceCleanup, "namespace-cleanup", true, "Clean up orphaned resources caused by namespace force-deletions") flag.BoolVar(&recOpts.FailOpen, "fail-open", false, "Report that resources are reconciled once they've been seen, even if reconciliation failed. Overridden by individual resources with 'eno.azure.io/fail-open: true|false'") + flag.StringVar(&migratingFieldManagers, "migrating-field-managers", "", "Comma-separated list of Kubernetes SSA field manager names to take ownership from during migrations") mgrOpts.Bind(flag.CommandLine) flag.Parse() @@ -124,6 +127,12 @@ func run() error { recOpts.Manager = mgr recOpts.WriteBuffer = writeBuffer recOpts.Downstream = remoteConfig + if migratingFieldManagers != "" { + recOpts.MigratingFieldManagers = strings.Split(migratingFieldManagers, ",") + for i := range recOpts.MigratingFieldManagers { + recOpts.MigratingFieldManagers[i] = strings.TrimSpace(recOpts.MigratingFieldManagers[i]) + } + } err = reconciliation.New(mgr, recOpts) if err != nil { diff --git a/internal/controllers/reconciliation/controller.go b/internal/controllers/reconciliation/controller.go index 6750df5f..527a1006 100644 --- a/internal/controllers/reconciliation/controller.go +++ b/internal/controllers/reconciliation/controller.go @@ -37,6 +37,7 @@ type Options struct { DisableServerSideApply bool FailOpen bool + MigratingFieldManagers []string Timeout time.Duration ReadinessPollInterval time.Duration @@ -44,16 +45,17 @@ type Options struct { } type Controller struct { - client client.Client - writeBuffer *flowcontrol.ResourceSliceWriteBuffer - resourceClient *resource.Cache - resourceFilter cel.Program - timeout time.Duration - readinessPollInterval time.Duration - upstreamClient client.Client - minReconcileInterval time.Duration - disableSSA bool - failOpen bool + client client.Client + writeBuffer *flowcontrol.ResourceSliceWriteBuffer + resourceClient *resource.Cache + resourceFilter cel.Program + timeout time.Duration + readinessPollInterval time.Duration + upstreamClient client.Client + minReconcileInterval time.Duration + disableSSA bool + failOpen bool + migratingFieldManagers []string } func New(mgr ctrl.Manager, opts Options) error { @@ -70,16 +72,17 @@ func New(mgr ctrl.Manager, opts Options) error { } c := &Controller{ - client: opts.Manager.GetClient(), - writeBuffer: opts.WriteBuffer, - resourceClient: cache, - resourceFilter: opts.ResourceFilter, - timeout: opts.Timeout, - readinessPollInterval: opts.ReadinessPollInterval, - upstreamClient: upstreamClient, - minReconcileInterval: opts.MinReconcileInterval, - disableSSA: opts.DisableServerSideApply, - failOpen: opts.FailOpen, + client: opts.Manager.GetClient(), + writeBuffer: opts.WriteBuffer, + resourceClient: cache, + resourceFilter: opts.ResourceFilter, + timeout: opts.Timeout, + readinessPollInterval: opts.ReadinessPollInterval, + upstreamClient: upstreamClient, + minReconcileInterval: opts.MinReconcileInterval, + disableSSA: opts.DisableServerSideApply, + failOpen: opts.FailOpen, + migratingFieldManagers: opts.MigratingFieldManagers, } return builder.TypedControllerManagedBy[resource.Request](mgr). @@ -281,18 +284,27 @@ func (c *Controller) reconcileSnapshot(ctx context.Context, comp *apiv1.Composit // When using server side apply, make sure we haven't lost any managedFields metadata. // Eno should always remove fields that are no longer set by the synthesizer, even if another client messed with managedFields. - if current != nil && prev != nil && !res.Replace { - snap, err := prev.SnapshotWithOverrides(ctx, comp, current, res.Resource) - if err != nil { - return false, fmt.Errorf("snapshotting previous version: %w", err) + // Also handle taking ownership from migrating field managers. + if current != nil && !res.Replace { + var dryRunPrev *unstructured.Unstructured + if prev != nil { + snap, err := prev.SnapshotWithOverrides(ctx, comp, current, res.Resource) + if err != nil { + return false, fmt.Errorf("snapshotting previous version: %w", err) + } + dryRunPrev = snap.Unstructured() + err = c.upstreamClient.Patch(ctx, dryRunPrev, client.Apply, client.ForceOwnership, client.FieldOwner("eno"), client.DryRunAll) + if err != nil { + return false, fmt.Errorf("getting managed fields values for previous version: %w", err) + } } - dryRunPrev := snap.Unstructured() - err = c.upstreamClient.Patch(ctx, dryRunPrev, client.Apply, client.ForceOwnership, client.FieldOwner("eno"), client.DryRunAll) - if err != nil { - return false, fmt.Errorf("getting managed fields values for previous version: %w", err) + + var prevManagedFields []metav1.ManagedFieldsEntry + if dryRunPrev != nil { + prevManagedFields = dryRunPrev.GetManagedFields() } - merged, fields, modified := resource.MergeEnoManagedFields(dryRunPrev.GetManagedFields(), current.GetManagedFields(), dryRun.GetManagedFields()) + merged, fields, modified := resource.MergeEnoManagedFields(prevManagedFields, current.GetManagedFields(), dryRun.GetManagedFields(), c.migratingFieldManagers) if modified { current.SetManagedFields(merged) diff --git a/internal/controllers/reconciliation/overrides_test.go b/internal/controllers/reconciliation/overrides_test.go index dd649237..e096afc9 100644 --- a/internal/controllers/reconciliation/overrides_test.go +++ b/internal/controllers/reconciliation/overrides_test.go @@ -847,3 +847,220 @@ func TestOverrideTransferResource(t *testing.T) { return syn1.Name == cm.Annotations["synthName"] }) } + +func TestMigratingFieldManagers(t *testing.T) { + ctx := testutil.NewContext(t) + mgr := testutil.NewManager(t) + upstream := mgr.GetClient() + + requireSSA(t, mgr) + registerControllers(t, mgr) + + // Use a variable to change Eno's desired state during resynthesis + enoValue := "eno-value" + testutil.WithFakeExecutor(t, mgr, func(ctx context.Context, s *apiv1.Synthesizer, input *krmv1.ResourceList) (*krmv1.ResourceList, error) { + output := &krmv1.ResourceList{} + output.Items = []*unstructured.Unstructured{{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-obj", + "namespace": "default", + }, + "data": map[string]any{"foo": enoValue}, + }, + }} + return output, nil + }) + + // Setup with migrating field managers + setupTestSubjectForOptions(t, mgr, Options{ + Manager: mgr.Manager, + Timeout: time.Minute, + ReadinessPollInterval: time.Hour, + DisableServerSideApply: mgr.NoSsaSupport, + MigratingFieldManagers: []string{"legacy-tool"}, + }) + mgr.Start(t) + _, comp := writeGenericComposition(t, upstream) + + // Wait for initial reconciliation + testutil.Eventually(t, func() bool { + return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil + }) + + // Resource should be created with Eno as the field manager + cm := &corev1.ConfigMap{} + cm.Name = "test-obj" + cm.Namespace = "default" + testutil.Eventually(t, func() bool { + err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + return err == nil && cm.Data["foo"] == "eno-value" + }) + + // Simulate a legacy tool taking ownership of a field by updating managed fields + // This simulates the scenario where a field was previously managed by another tool + err := retry.RetryOnConflict(testutil.Backoff, func() error { + err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + if err != nil { + return err + } + cm.ManagedFields = nil + cm.Data["bar"] = "legacy-value" + cm.APIVersion = "v1" + cm.Kind = "ConfigMap" + return mgr.DownstreamClient.Patch(ctx, cm, client.Apply, client.ForceOwnership, client.FieldOwner("legacy-tool")) + }) + require.NoError(t, err) + + // Verify the field is owned by legacy-tool + testutil.Eventually(t, func() bool { + mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + for _, entry := range cm.GetManagedFields() { + if entry.Manager == "legacy-tool" { + return true + } + } + return false + }) + + // Change Eno's desired state and force a resynthesis to trigger field manager migration + enoValue = "eno-value-updated" + err = retry.RetryOnConflict(testutil.Backoff, func() error { + upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) + comp.Spec.SynthesisEnv = []apiv1.EnvVar{{Name: "TRIGGER", Value: "resynthesis"}} + return upstream.Update(ctx, comp) + }) + require.NoError(t, err) + + // Wait for reconciliation + testutil.Eventually(t, func() bool { + return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil + }) + + // Verify that Eno has taken ownership from legacy-tool + testutil.Eventually(t, func() bool { + mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + hasEno := false + hasLegacy := false + for _, entry := range cm.GetManagedFields() { + if entry.Manager == "eno" { + hasEno = true + } + if entry.Manager == "legacy-tool" { + hasLegacy = true + } + } + // Eno should have taken ownership, and legacy-tool should no longer own fields + // (or should own an empty set of fields) + // Also verify the value was updated + return hasEno && !hasLegacy && cm.Data["foo"] == "eno-value-updated" + }) +} + +func TestMigratingFieldManagersFieldRemoval(t *testing.T) { + ctx := testutil.NewContext(t) + mgr := testutil.NewManager(t) + upstream := mgr.GetClient() + + requireSSA(t, mgr) + registerControllers(t, mgr) + + // Start with synthesizer that includes a field + includeField := true + fooValue := "eno-value" + testutil.WithFakeExecutor(t, mgr, func(ctx context.Context, s *apiv1.Synthesizer, input *krmv1.ResourceList) (*krmv1.ResourceList, error) { + output := &krmv1.ResourceList{} + data := map[string]any{"foo": fooValue} + if includeField { + data["bar"] = "eno-bar-value" + } + output.Items = []*unstructured.Unstructured{{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-obj", + "namespace": "default", + }, + "data": data, + }, + }} + return output, nil + }) + + // Setup with migrating field managers + setupTestSubjectForOptions(t, mgr, Options{ + Manager: mgr.Manager, + Timeout: time.Minute, + ReadinessPollInterval: time.Hour, + DisableServerSideApply: mgr.NoSsaSupport, + MigratingFieldManagers: []string{"legacy-tool"}, + }) + mgr.Start(t) + _, comp := writeGenericComposition(t, upstream) + + // Wait for initial reconciliation + testutil.Eventually(t, func() bool { + return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil + }) + + cm := &corev1.ConfigMap{} + cm.Name = "test-obj" + cm.Namespace = "default" + testutil.Eventually(t, func() bool { + err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + return err == nil && cm.Data["bar"] == "eno-bar-value" + }) + + // Simulate legacy tool taking ownership of the "bar" field + err := retry.RetryOnConflict(testutil.Backoff, func() error { + err := mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + if err != nil { + return err + } + cm.ManagedFields = nil + cm.Data["bar"] = "legacy-bar-value" + cm.APIVersion = "v1" + cm.Kind = "ConfigMap" + return mgr.DownstreamClient.Patch(ctx, cm, client.Apply, client.ForceOwnership, client.FieldOwner("legacy-tool")) + }) + require.NoError(t, err) + + // Verify legacy-tool owns the field + testutil.Eventually(t, func() bool { + mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + return cm.Data["bar"] == "legacy-bar-value" + }) + + // Now remove the field from Eno's desired state and change foo to trigger reconciliation + includeField = false + fooValue = "eno-value-updated" + + // Force resynthesis + err = retry.RetryOnConflict(testutil.Backoff, func() error { + upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) + comp.Spec.SynthesisEnv = []apiv1.EnvVar{{Name: "REMOVE_FIELD", Value: "true"}} + return upstream.Update(ctx, comp) + }) + require.NoError(t, err) + + // Wait for reconciliation + testutil.Eventually(t, func() bool { + return upstream.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil && comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.Ready != nil + }) + + // The critical test: since Eno took ownership from legacy-tool, + // it should be able to remove the field even though it was originally owned by legacy-tool. + // This is the whole point of the migration feature - to allow safe field removal during migrations. + testutil.Eventually(t, func() bool { + mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm) + _, exists := cm.Data["bar"] + return !exists && cm.Data["foo"] == "eno-value-updated" // Field should be removed and foo should be updated + }) + + // Verify foo is still present with the updated value + require.NoError(t, mgr.DownstreamClient.Get(ctx, client.ObjectKeyFromObject(cm), cm)) + assert.Equal(t, "eno-value-updated", cm.Data["foo"]) +} diff --git a/internal/resource/fieldmanager.go b/internal/resource/fieldmanager.go index 18118163..3f228bad 100644 --- a/internal/resource/fieldmanager.go +++ b/internal/resource/fieldmanager.go @@ -11,38 +11,57 @@ import ( // MergeEnoManagedFields corrects managed fields drift to ensure Eno can remove fields // that are no longer set by the synthesizer, even when another client corrupts the -// managed fields metadata. Returns corrected managed fields, affected field paths, +// managed fields metadata. It also takes ownership of fields from migrating field managers +// when specified. Returns corrected managed fields, affected field paths, // and whether correction was needed. -func MergeEnoManagedFields(prev, current, next []metav1.ManagedFieldsEntry) (copy []metav1.ManagedFieldsEntry, fields string, modified bool) { +func MergeEnoManagedFields(prev, current, next []metav1.ManagedFieldsEntry, migratingFieldManagers []string) (copy []metav1.ManagedFieldsEntry, fields string, modified bool) { prevEnoSet := parseEnoFields(prev) nextEnoSet := parseEnoFields(next) - - if prevEnoSet.Empty() { - return nil, "", false - } - currentEnoSet := parseEnoFields(current) + // Check for fields to take from migrating field managers + migratingFields := parseMigratingFields(current, migratingFieldManagers) + var expectedFields *fieldpath.Set - if !nextEnoSet.Empty() && currentEnoSet.Empty() { - expectedFields = prevEnoSet - } else { - expectedFields = prevEnoSet.Difference(nextEnoSet) - if expectedFields.Empty() { - return nil, "", false + + // Handle the drift correction logic (only when Eno has previously managed this resource) + if !prevEnoSet.Empty() { + if !nextEnoSet.Empty() && currentEnoSet.Empty() { + expectedFields = prevEnoSet + } else if driftFields := prevEnoSet.Difference(nextEnoSet); !driftFields.Empty() { + if driftFields = driftFields.Intersection(parseAllFields(current)); !driftFields.Empty() { + expectedFields = driftFields + } } + } - expectedFields = expectedFields.Intersection(parseAllFields(current)) - if expectedFields.Empty() { - return nil, "", false + // Add fields from migrating field managers + // This works even on first reconciliation when prevEnoSet is empty + if !migratingFields.Empty() { + if expectedFields == nil { + expectedFields = migratingFields + } else { + expectedFields = expectedFields.Union(migratingFields) } } - return adjustManagedFields(prev, expectedFields), expectedFields.String(), true + if expectedFields == nil || expectedFields.Empty() { + return nil, "", false + } + + // When there's no previous Eno fields but we have migrating fields, + // we need to build the managed fields from current state + base := prev + if prevEnoSet.Empty() && !migratingFields.Empty() { + base = current + } + + return adjustManagedFields(base, expectedFields), expectedFields.String(), true } func adjustManagedFields(entries []metav1.ManagedFieldsEntry, expected *fieldpath.Set) []metav1.ManagedFieldsEntry { copy := make([]metav1.ManagedFieldsEntry, 0, len(entries)) + hasEno := false for _, entry := range entries { if entry.FieldsV1 == nil { @@ -58,6 +77,7 @@ func adjustManagedFields(entries []metav1.ManagedFieldsEntry, expected *fieldpat var updated *fieldpath.Set if entry.Manager == "eno" && entry.Operation == metav1.ManagedFieldsOperationApply { + hasEno = true updated = set.Union(expected) } else { updated = set.Difference(expected) @@ -73,6 +93,19 @@ func adjustManagedFields(entries []metav1.ManagedFieldsEntry, expected *fieldpat copy = append(copy, entry) } + // If there's no "eno" entry yet, add one with the expected fields + if !hasEno && !expected.Empty() { + js, err := expected.ToJSON() + if err == nil { + copy = append(copy, metav1.ManagedFieldsEntry{ + Manager: "eno", + Operation: metav1.ManagedFieldsOperationApply, + FieldsType: "FieldsV1", + FieldsV1: &metav1.FieldsV1{Raw: js}, + }) + } + } + return copy } @@ -99,6 +132,22 @@ func parseAllFields(entries []metav1.ManagedFieldsEntry) *fieldpath.Set { return result } +func parseMigratingFields(entries []metav1.ManagedFieldsEntry, migratingFieldManagers []string) *fieldpath.Set { + result := &fieldpath.Set{} + if len(migratingFieldManagers) == 0 { + return result + } + for _, entry := range entries { + if !slices.Contains(migratingFieldManagers, entry.Manager) { + continue + } + if set := parseFieldsEntry(entry); set != nil { + result = result.Union(set) + } + } + return result +} + // parseFieldsEntry safely parses a single managed fields entry func parseFieldsEntry(entry metav1.ManagedFieldsEntry) *fieldpath.Set { if entry.FieldsV1 == nil { diff --git a/internal/resource/fieldmanager_test.go b/internal/resource/fieldmanager_test.go index 2895df43..7b8a0128 100644 --- a/internal/resource/fieldmanager_test.go +++ b/internal/resource/fieldmanager_test.go @@ -252,7 +252,7 @@ func TestManagedFields(t *testing.T) { for _, tc := range tests { t.Run(tc.Name, func(t *testing.T) { - merged, _, modified := MergeEnoManagedFields(tc.Previous, tc.Current, tc.Next) + merged, _, modified := MergeEnoManagedFields(tc.Previous, tc.Current, tc.Next, nil) assert.Equal(t, tc.ExpectModified, modified) assert.Equal(t, parseFieldEntries(tc.Expected), parseFieldEntries(merged)) @@ -296,3 +296,128 @@ func parseFieldEntries(entries []metav1.ManagedFieldsEntry) []*fieldpath.Set { } return sets } + +func TestMigratingFieldManagers(t *testing.T) { + tests := []struct { + Name string + ExpectModified bool + Previous, Current, Next []metav1.ManagedFieldsEntry + MigratingManagers []string + Expected []metav1.ManagedFieldsEntry + }{ + { + Name: "take ownership from single migrating manager", + ExpectModified: true, + Previous: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + Current: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + makeFields(t, "legacy-tool", []string{"bar"}), + }, + Next: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + MigratingManagers: []string{"legacy-tool"}, + Expected: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo", "bar"}), + }, + }, + { + Name: "take ownership from multiple migrating managers", + ExpectModified: true, + Previous: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + Current: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + makeFields(t, "legacy-tool-1", []string{"bar"}), + makeFields(t, "legacy-tool-2", []string{"baz"}), + }, + Next: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + MigratingManagers: []string{"legacy-tool-1", "legacy-tool-2"}, + Expected: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo", "bar", "baz"}), + }, + }, + { + Name: "no migrating managers configured", + ExpectModified: false, + Previous: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + Current: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + makeFields(t, "other-tool", []string{"bar"}), + }, + Next: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + MigratingManagers: nil, + }, + { + Name: "migrating manager not present in current", + ExpectModified: false, + Previous: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + Current: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + Next: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + MigratingManagers: []string{"legacy-tool"}, + }, + { + Name: "take ownership from migrating manager and handle drift", + ExpectModified: true, + Previous: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo", "removed"}), + }, + Current: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + makeFields(t, "drift-tool", []string{"removed"}), + makeFields(t, "legacy-tool", []string{"bar"}), + }, + Next: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo"}), + }, + MigratingManagers: []string{"legacy-tool"}, + Expected: []metav1.ManagedFieldsEntry{ + makeFields(t, "eno", []string{"foo", "removed", "bar"}), + }, + }, + { + Name: "empty previous eno fields with migrating manager - first reconciliation", + ExpectModified: true, + Previous: []metav1.ManagedFieldsEntry{}, + Current: []metav1.ManagedFieldsEntry{ + makeFields(t, "legacy-tool", []string{"bar"}), + }, + Next: []metav1.ManagedFieldsEntry{}, + MigratingManagers: []string{"legacy-tool"}, + Expected: []metav1.ManagedFieldsEntry{ + makeFields(t, "legacy-tool", []string{}), // Fields removed from legacy-tool + makeFields(t, "eno", []string{"bar"}), // Fields added to eno + }, + }, + } + + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + merged, _, modified := MergeEnoManagedFields(tc.Previous, tc.Current, tc.Next, tc.MigratingManagers) + assert.Equal(t, tc.ExpectModified, modified) + if tc.Expected != nil { + assert.Equal(t, parseFieldEntries(tc.Expected), parseFieldEntries(merged)) + } + + // Prove that the current slice wasn't mutated + if tc.ExpectModified { + assert.NotEqual(t, tc.Current, merged) + } + }) + } +}