Skip to content

Commit 4db8fa1

Browse files
committed
Fix hash instability, file collision, and sort performance in addon-controller
Silent file loss when deploying from Flux sources with nested directories When reading files from a Flux source path, the code was keying each file by its bare filename rather than its path relative to the source root. If two files in different subdirectories shared the same name, the second one silently overwrote the first, causing part of the content to be dropped without any error. The fix uses the relative path as the key. Non-deterministic hash for DriftExclusion ordering SortedDriftExclusions.Less() compared entries using only their Paths field. Two entries with identical paths but different Target fields compared as equal, so the sort was free to place them in any order. Because the sorted slice feeds directly into the spec hash used by ClusterPromotionReconciler, the hash could differ between runs for semantically identical specs, triggering spurious promotion re-evaluations. The sort now includes Target (Group, Version, Kind, Namespace, Name) as a stable tiebreaker. Profile spec hash was non-deterministic for map fields and used a slow renderer
1 parent 87f79c0 commit 4db8fa1

6 files changed

Lines changed: 162 additions & 29 deletions

File tree

controllers/clusterpromotion_controller_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ var _ = Describe("ClusterPromotionController", func() {
6666
sourceField := profileSpecType.Field(i)
6767

6868
// Those fields are only present in ClusterProfile.Spec
69-
if sourceField.Name == ClusterSelectorField || sourceField.Name == "ClusterRefs" ||
70-
sourceField.Name == "SetRefs" {
69+
if sourceField.Name == ClusterSelectorField || sourceField.Name == clusterRefsField ||
70+
sourceField.Name == setRefsField {
7171

7272
_, found := clusterPromotionProfileSpecField.FieldByName(sourceField.Name)
7373
if found {

controllers/handlers_utils.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ func readFiles(dir string) (map[string]string, error) {
207207
return err
208208
}
209209

210-
files[d.Name()] = string(content)
210+
files[rel] = string(content)
211211
}
212212
return nil
213213
})

controllers/handlers_utils_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1102,7 +1102,7 @@ metadata:
11021102
Expect(ok).To(BeTrue())
11031103
Expect(v).To(Equal(serviceTemplate))
11041104

1105-
v, ok = result["file2.txt"]
1105+
v, ok = result[filepath.Join(subdir, "file2.txt")]
11061106
Expect(ok).To(BeTrue())
11071107
Expect(v).To(Equal(deplTemplate))
11081108
})

controllers/profile_hash_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
Copyright 2022. projectsveltos.io. All rights reserved.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controllers_test
18+
19+
import (
20+
"reflect"
21+
22+
. "github.com/onsi/ginkgo/v2"
23+
. "github.com/onsi/gomega"
24+
25+
configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1"
26+
)
27+
28+
const (
29+
// clusterRefsField and setRefsField name the two Spec slice fields that
30+
// are explicitly excluded from hash normalisation (their order is
31+
// author-determined). The constants are also used by
32+
// clusterpromotion_controller_test.go.
33+
clusterRefsField = "ClusterRefs"
34+
setRefsField = "SetRefs"
35+
)
36+
37+
var _ = Describe("getProfileSpecHash slice-field coverage", func() {
38+
// This test guards against getProfileSpecHash silently ignoring newly
39+
// added slice fields in configv1beta1.Spec. json.Marshal includes every
40+
// field automatically, but slice fields must also be *sorted* before
41+
// marshaling so the hash is stable regardless of element order.
42+
//
43+
// When a new slice field is added to configv1beta1.Spec:
44+
// • Add a getSorted* helper (or reuse an existing one) in sort.go
45+
// • Call it inside getProfileSpecHash in profile_utils.go
46+
// • Add the field name to sortedFields below
47+
//
48+
// If its element order is always deterministic (e.g. explicit single-item
49+
// refs typed by the user with no meaningful reordering), add it to
50+
// orderDeterministic instead.
51+
It("accounts for every slice field in configv1beta1.Spec", func() {
52+
// Fields that getProfileSpecHash explicitly sorts before hashing.
53+
sortedFields := map[string]bool{
54+
"HelmCharts": true,
55+
"PolicyRefs": true,
56+
"KustomizationRefs": true,
57+
"TemplateResourceRefs": true,
58+
"ValidateHealths": true,
59+
"PreDeployChecks": true,
60+
"PreDeleteChecks": true,
61+
"PostDeleteChecks": true,
62+
"Patches": true,
63+
"PatchesFrom": true,
64+
"DriftExclusions": true,
65+
"DependsOn": true,
66+
}
67+
// Slice fields whose element order is author-determined and therefore
68+
// does not need normalisation to produce a stable hash.
69+
orderDeterministic := map[string]bool{
70+
clusterRefsField: true,
71+
setRefsField: true,
72+
}
73+
74+
specType := reflect.TypeOf(configv1beta1.Spec{})
75+
for i := 0; i < specType.NumField(); i++ {
76+
f := specType.Field(i)
77+
if f.Type.Kind() != reflect.Slice {
78+
continue
79+
}
80+
Expect(sortedFields[f.Name] || orderDeterministic[f.Name]).To(BeTrue(),
81+
"Spec.%s is a slice field not yet handled by getProfileSpecHash.\n"+
82+
"Add sorting for it in getProfileSpecHash (profile_utils.go) and\n"+
83+
"list it in 'sortedFields' in this test, or add it to\n"+
84+
"'orderDeterministic' if its element order is always stable.", f.Name)
85+
}
86+
})
87+
})

controllers/profile_utils.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ package controllers
1919
import (
2020
"context"
2121
"crypto/sha256"
22+
"encoding/json"
2223
"fmt"
2324
"math"
2425
"reflect"
26+
"sort"
2527
"strings"
2628

2729
"github.com/dariubs/percent"
28-
"github.com/gdexlab/go-render/render"
2930
"github.com/go-logr/logr"
3031
"github.com/pkg/errors"
3132
corev1 "k8s.io/api/core/v1"
@@ -1066,14 +1067,31 @@ func cleanClusterReports(ctx context.Context, c client.Client, profileScope *sco
10661067
return nil
10671068
}
10681069

1069-
// getProfileSpecHash returns hash of current clusterProfile/Profile Spec
1070+
// getProfileSpecHash returns hash of current clusterProfile/Profile Spec.
1071+
// All slice fields are sorted before marshaling so the hash is stable
1072+
// regardless of the order in which items appear in the original spec.
10701073
func getProfileSpecHash(profileScope *scope.ProfileScope) []byte {
10711074
h := sha256.New()
1072-
var config string
10731075

1074-
config += render.AsCode(profileScope.GetSpec())
1075-
1076-
h.Write([]byte(config))
1076+
specCopy := *profileScope.GetSpec()
1077+
specCopy.HelmCharts = getSortedHelmCharts(specCopy.HelmCharts)
1078+
specCopy.PolicyRefs = getSortedPolicyRefs(specCopy.PolicyRefs)
1079+
specCopy.KustomizationRefs = getSortedKustomizationRefs(specCopy.KustomizationRefs)
1080+
specCopy.TemplateResourceRefs = getSortedTemplateResourceRefs(specCopy.TemplateResourceRefs)
1081+
specCopy.ValidateHealths = getSortedValidateHealths(specCopy.ValidateHealths)
1082+
specCopy.PreDeployChecks = getSortedValidateHealths(specCopy.PreDeployChecks)
1083+
specCopy.PreDeleteChecks = getSortedValidateHealths(specCopy.PreDeleteChecks)
1084+
specCopy.PostDeleteChecks = getSortedValidateHealths(specCopy.PostDeleteChecks)
1085+
specCopy.Patches = getSortedPatches(specCopy.Patches)
1086+
specCopy.PatchesFrom = getSortedValueFroms(specCopy.PatchesFrom)
1087+
specCopy.DriftExclusions = getSortedDriftExclusions(specCopy.DriftExclusions)
1088+
specCopy.DependsOn = make([]string, len(specCopy.DependsOn))
1089+
copy(specCopy.DependsOn, profileScope.GetSpec().DependsOn)
1090+
sort.Strings(specCopy.DependsOn)
1091+
1092+
if data, err := json.Marshal(specCopy); err == nil {
1093+
h.Write(data)
1094+
}
10771095
return h.Sum(nil)
10781096
}
10791097

controllers/sort.go

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -138,31 +138,59 @@ func getSortedPatches(patches []libsveltosv1beta1.Patch) []libsveltosv1beta1.Pat
138138
return sortedPatches
139139
}
140140

141-
type SortedDriftExclusions []libsveltosv1beta1.DriftExclusion
142-
143-
func (a SortedDriftExclusions) Len() int { return len(a) }
144-
func (a SortedDriftExclusions) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
145-
func (a SortedDriftExclusions) Less(i, j int) bool {
146-
pathsI := make([]string, len(a[i].Paths))
147-
copy(pathsI, a[i].Paths)
148-
sort.Strings(pathsI)
141+
// driftExclusionEntry pairs a DriftExclusion with its pre-computed sort key
142+
// so the sort comparator can reference the slice being sorted.
143+
type driftExclusionEntry struct {
144+
item libsveltosv1beta1.DriftExclusion
145+
key string
146+
}
149147

150-
pathsJ := make([]string, len(a[j].Paths))
151-
copy(pathsJ, a[j].Paths)
152-
sort.Strings(pathsJ)
148+
func getSortedDriftExclusions(driftExclusions []libsveltosv1beta1.DriftExclusion) []libsveltosv1beta1.DriftExclusion {
149+
// Pre-compute a stable sort key for each element once. The key encodes
150+
// sorted Paths (so path order doesn't matter) plus Target fields as a
151+
// tiebreaker (so entries with identical paths sort deterministically).
152+
entries := make([]driftExclusionEntry, len(driftExclusions))
153+
for i := range driftExclusions {
154+
p := make([]string, len(driftExclusions[i].Paths))
155+
copy(p, driftExclusions[i].Paths)
156+
sort.Strings(p)
157+
key := strings.Join(p, "\x00")
158+
if t := driftExclusions[i].Target; t != nil {
159+
key += "\x01" + t.Group + "\x00" + t.Version + "\x00" + t.Kind + "\x00" + t.Namespace + "\x00" + t.Name
160+
}
161+
entries[i] = driftExclusionEntry{item: driftExclusions[i], key: key}
162+
}
153163

154-
strI := strings.Join(pathsI, "|")
155-
strJ := strings.Join(pathsJ, "|")
164+
sort.SliceStable(entries, func(i, j int) bool {
165+
return entries[i].key < entries[j].key
166+
})
156167

157-
return strI < strJ
168+
sorted := make([]libsveltosv1beta1.DriftExclusion, len(entries))
169+
for i := range entries {
170+
sorted[i] = entries[i].item
171+
}
172+
return sorted
158173
}
159174

160-
func getSortedDriftExclusions(driftExclusions []libsveltosv1beta1.DriftExclusion) []libsveltosv1beta1.DriftExclusion {
161-
sortedDriftExclusions := make([]libsveltosv1beta1.DriftExclusion, len(driftExclusions))
162-
copy(sortedDriftExclusions, driftExclusions)
175+
type SortedValueFroms []configv1beta1.ValueFrom
176+
177+
func (a SortedValueFroms) Len() int { return len(a) }
178+
func (a SortedValueFroms) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
179+
func (a SortedValueFroms) Less(i, j int) bool {
180+
if a[i].Kind != a[j].Kind {
181+
return a[i].Kind < a[j].Kind
182+
}
183+
if a[i].Namespace != a[j].Namespace {
184+
return a[i].Namespace < a[j].Namespace
185+
}
186+
return a[i].Name < a[j].Name
187+
}
163188

164-
sort.Sort(SortedDriftExclusions(sortedDriftExclusions))
165-
return sortedDriftExclusions
189+
func getSortedValueFroms(valueFroms []configv1beta1.ValueFrom) []configv1beta1.ValueFrom {
190+
sorted := make([]configv1beta1.ValueFrom, len(valueFroms))
191+
copy(sorted, valueFroms)
192+
sort.Sort(SortedValueFroms(sorted))
193+
return sorted
166194
}
167195

168196
func getSortedKeys(m interface{}) []string {

0 commit comments

Comments
 (0)