Skip to content

Commit abd7004

Browse files
authored
Deduplicate appended slices in options.Merge (#5197)
options.Merge appended DownstreamResources, Helm.ValuesFrom, and Diff.ComparePatches slices without checking for duplicates. When both base and a per-target customization reference the same entry, the same resource reference ended up in the merged list twice. Extract a generic merge.ByKey helper to internal/merge. It iterates base and custom once each (O(n) per slice), replaces a base entry in-place when the custom entry shares its key, and appends otherwise. The collision behaviour is caller-defined via an onCollide function. - options.mergeUnique wraps merge.ByKey with a replace-with-custom collision handler, consistent with how the rest of options.Merge treats per-target customizations. - bundlediff.mergeComparePatches is simplified to merge.ByKey with a union-operations collision handler: existing operations are kept and new ones not already present are appended. The result is then sorted for deterministic output. Key functions: - downstreamResourceKey: strings.ToLower(Kind)+"|"+Name (case-insensitive Kind) - valuesFromKey: source-type+"|"+Namespace+"|"+Name+"|"+Key - comparePatchKey (options) / patchIdentityKey (bundlediff): APIVersion+"|"+Kind+"|"+Namespace+"|"+Name
1 parent c034c9e commit abd7004

6 files changed

Lines changed: 440 additions & 47 deletions

File tree

internal/cmd/cli/bundlediff.go

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"sigs.k8s.io/yaml"
2020

2121
command "github.com/rancher/fleet/internal/cmd"
22+
"github.com/rancher/fleet/internal/merge"
2223
fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
2324
)
2425

@@ -249,54 +250,57 @@ func convertMergePatchToRemoveOps(patch map[string]any, basePath string) []patch
249250
return ops
250251
}
251252

252-
// mergeComparePatches merges new comparePatches with existing ones, avoiding duplicates.
253-
// This preserves any user-configured comparePatches while adding new ones from drift detection.
254-
func mergeComparePatches(existing, new []fleet.ComparePatch) []fleet.ComparePatch {
255-
patchMap := make(map[string]fleet.ComparePatch)
253+
// patchIdentityKey returns the key that identifies a ComparePatch resource.
254+
func patchIdentityKey(p fleet.ComparePatch) string {
255+
return p.APIVersion + "|" + p.Kind + "|" + p.Namespace + "|" + p.Name
256+
}
256257

257-
// Key function for resource identity
258-
resourceKey := func(p fleet.ComparePatch) string {
259-
return fmt.Sprintf("%s|%s|%s|%s", p.APIVersion, p.Kind, p.Namespace, p.Name)
258+
// unionComparePatchOps merges two ComparePatch entries for the same resource by
259+
// unioning their operations: base operations are kept and custom operations not
260+
// already present in base are appended.
261+
func unionComparePatchOps(base, custom fleet.ComparePatch) fleet.ComparePatch {
262+
opSet := make(map[string]struct{}, len(base.Operations))
263+
for _, op := range base.Operations {
264+
opSet[op.Op+"|"+op.Path+"|"+op.Value] = struct{}{}
265+
}
266+
result := base
267+
for _, op := range custom.Operations {
268+
if _, exists := opSet[op.Op+"|"+op.Path+"|"+op.Value]; !exists {
269+
result.Operations = append(result.Operations, op)
270+
}
260271
}
272+
return result
273+
}
261274

262-
for _, patch := range existing {
263-
key := resourceKey(patch)
264-
patchMap[key] = patch
265-
}
275+
// mergeComparePatches merges new comparePatches with existing ones.
276+
// For the same resource identity, operations are unioned — existing operations
277+
// are kept and new operations not already present are appended. The result is
278+
// sorted by resource identity for deterministic output.
279+
func mergeComparePatches(existing, new []fleet.ComparePatch) []fleet.ComparePatch {
280+
// Consolidate duplicate resource identities within new by unioning their
281+
// operations before merging with existing, so no operations are dropped.
282+
consolidated := consolidatePatches(new)
283+
merged := merge.ByKey(existing, consolidated, patchIdentityKey, unionComparePatchOps)
284+
sort.Slice(merged, func(i, j int) bool {
285+
return patchIdentityKey(merged[i]) < patchIdentityKey(merged[j])
286+
})
287+
return merged
288+
}
266289

267-
// Merge new patches, combining operations for same resource
268-
for _, newPatch := range new {
269-
key := resourceKey(newPatch)
270-
if existingPatch, found := patchMap[key]; found {
271-
merged := existingPatch
272-
opSet := make(map[string]bool)
273-
for _, op := range existingPatch.Operations {
274-
opKey := fmt.Sprintf("%s|%s|%s", op.Op, op.Path, op.Value)
275-
opSet[opKey] = true
276-
}
277-
for _, op := range newPatch.Operations {
278-
opKey := fmt.Sprintf("%s|%s|%s", op.Op, op.Path, op.Value)
279-
if !opSet[opKey] {
280-
merged.Operations = append(merged.Operations, op)
281-
}
282-
}
283-
patchMap[key] = merged
290+
// consolidatePatches folds entries with the same resource identity by unioning
291+
// their operations, preserving the order of first occurrence.
292+
func consolidatePatches(patches []fleet.ComparePatch) []fleet.ComparePatch {
293+
seen := make(map[string]int, len(patches))
294+
result := make([]fleet.ComparePatch, 0, len(patches))
295+
for _, p := range patches {
296+
k := patchIdentityKey(p)
297+
if idx, ok := seen[k]; ok {
298+
result[idx] = unionComparePatchOps(result[idx], p)
284299
} else {
285-
patchMap[key] = newPatch
300+
seen[k] = len(result)
301+
result = append(result, p)
286302
}
287303
}
288-
289-
// Convert map back to slice, sorted for deterministic output
290-
var result []fleet.ComparePatch
291-
keys := make([]string, 0, len(patchMap))
292-
for k := range patchMap {
293-
keys = append(keys, k)
294-
}
295-
sort.Strings(keys)
296-
for _, k := range keys {
297-
result = append(result, patchMap[k])
298-
}
299-
300304
return result
301305
}
302306

internal/cmd/cli/bundlediff_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,46 @@ func TestMergeComparePatches(t *testing.T) {
6363
}
6464
}
6565

66+
func TestMergeComparePatches_DuplicateNewEntries(t *testing.T) {
67+
// Two new entries for the same resource identity must have their operations
68+
// unioned, not dropped.
69+
newPatches := []fleet.ComparePatch{
70+
{
71+
APIVersion: "v1",
72+
Kind: "ConfigMap",
73+
Name: "app-config",
74+
Namespace: "default",
75+
Operations: []fleet.Operation{{Op: "remove", Path: "/data/a"}},
76+
},
77+
{
78+
APIVersion: "v1",
79+
Kind: "ConfigMap",
80+
Name: "app-config",
81+
Namespace: "default",
82+
Operations: []fleet.Operation{{Op: "remove", Path: "/data/b"}},
83+
},
84+
}
85+
86+
expected := []fleet.ComparePatch{
87+
{
88+
APIVersion: "v1",
89+
Kind: "ConfigMap",
90+
Name: "app-config",
91+
Namespace: "default",
92+
Operations: []fleet.Operation{
93+
{Op: "remove", Path: "/data/a"},
94+
{Op: "remove", Path: "/data/b"},
95+
},
96+
},
97+
}
98+
99+
merged := mergeComparePatches(nil, newPatches)
100+
101+
if diff := cmp.Diff(expected, merged); diff != "" {
102+
t.Errorf("mergeComparePatches() mismatch (-want +got):\n%s", diff)
103+
}
104+
}
105+
66106
func TestConvertMergePatchToRemoveOps(t *testing.T) {
67107
tests := []struct {
68108
name string

internal/cmd/controller/options/calculate.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,37 @@ import (
66
"encoding/hex"
77
"encoding/json"
88
"maps"
9+
"strings"
910

11+
"github.com/rancher/fleet/internal/merge"
1012
fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
1113
"github.com/rancher/wrangler/v3/pkg/data"
1214
)
1315

16+
// mergeUnique merges two slices by key, with custom entries taking precedence
17+
// over base entries for the same key. See merge.ByKey for full semantics.
18+
func mergeUnique[T any](base, custom []T, keyFn func(T) string) []T {
19+
return merge.ByKey(base, custom, keyFn, func(_ T, c T) T { return c })
20+
}
21+
22+
func downstreamResourceKey(r fleet.DownstreamResource) string {
23+
return strings.ToLower(r.Kind) + "|" + r.Name
24+
}
25+
26+
func valuesFromKey(v fleet.ValuesFrom) string {
27+
if v.ConfigMapKeyRef != nil {
28+
return "configmap|" + v.ConfigMapKeyRef.Namespace + "|" + v.ConfigMapKeyRef.Name + "|" + v.ConfigMapKeyRef.Key
29+
}
30+
if v.SecretKeyRef != nil {
31+
return "secret|" + v.SecretKeyRef.Namespace + "|" + v.SecretKeyRef.Name + "|" + v.SecretKeyRef.Key
32+
}
33+
return "" // no stable identity; always append
34+
}
35+
36+
func comparePatchKey(p fleet.ComparePatch) string {
37+
return p.APIVersion + "|" + p.Kind + "|" + p.Namespace + "|" + p.Name
38+
}
39+
1440
// DeploymentID hashes the options to a string
1541
func DeploymentID(manifestID string, opts fleet.BundleDeploymentOptions) (string, error) {
1642
h := sha256.New()
@@ -64,8 +90,8 @@ func Merge(base, custom fleet.BundleDeploymentOptions) fleet.BundleDeploymentOpt
6490
} else if custom.Helm.TemplateValues != nil {
6591
maps.Copy(result.Helm.TemplateValues, custom.Helm.TemplateValues)
6692
}
67-
if custom.Helm.ValuesFrom != nil {
68-
result.Helm.ValuesFrom = append(result.Helm.ValuesFrom, custom.Helm.ValuesFrom...)
93+
if len(custom.Helm.ValuesFrom) > 0 {
94+
result.Helm.ValuesFrom = mergeUnique(result.Helm.ValuesFrom, custom.Helm.ValuesFrom, valuesFromKey)
6995
}
7096
if custom.Helm.Repo != "" {
7197
result.Helm.Repo = custom.Helm.Repo
@@ -98,7 +124,7 @@ func Merge(base, custom fleet.BundleDeploymentOptions) fleet.BundleDeploymentOpt
98124
if result.Diff == nil {
99125
result.Diff = &fleet.DiffOptions{}
100126
}
101-
result.Diff.ComparePatches = append(result.Diff.ComparePatches, custom.Diff.ComparePatches...)
127+
result.Diff.ComparePatches = mergeUnique(result.Diff.ComparePatches, custom.Diff.ComparePatches, comparePatchKey)
102128
}
103129
if custom.YAML != nil {
104130
if result.YAML == nil {
@@ -114,7 +140,7 @@ func Merge(base, custom fleet.BundleDeploymentOptions) fleet.BundleDeploymentOpt
114140
result.CorrectDrift = custom.CorrectDrift
115141
}
116142
if len(custom.DownstreamResources) > 0 {
117-
result.DownstreamResources = append(result.DownstreamResources, custom.DownstreamResources...)
143+
result.DownstreamResources = mergeUnique(result.DownstreamResources, custom.DownstreamResources, downstreamResourceKey)
118144
}
119145

120146
return result

0 commit comments

Comments
 (0)