Skip to content

Commit 30b131a

Browse files
longquan0104hiddeco
andcommitted
Stable sort release values by key
This commit changes the way the checksum is calculated for the release values, by stable sorting the keys. By doing this, an upgrade will not be triggered when a key/value pair has just been moved, instead of containing a real change of value. To make it backwards compatible (and without triggering an upgrade due to new ordering), the checksum without ordering is continued to be calculated and compared against until removal in a future controller release. However, only the checksum of the ordered values is taken note of in the Status of the HelmRelease. Co-authored-by: Hidde Beydals <hidde@hhh.computer> Signed-off-by: longquan0104 <longquan0104@gmail.com>
1 parent 2d1dbc1 commit 30b131a

File tree

5 files changed

+277
-4
lines changed

5 files changed

+277
-4
lines changed

api/v2beta1/helmrelease_types.go

+27
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,8 @@ func HelmReleaseReady(hr HelmRelease) HelmRelease {
945945

946946
// HelmReleaseAttempted registers an attempt of the given HelmRelease with the given state.
947947
// and returns the modified HelmRelease and a boolean indicating a state change.
948+
//
949+
// Deprecated: in favor of HelmReleaseChanged and HelmReleaseRecordAttempt.
948950
func HelmReleaseAttempted(hr HelmRelease, revision string, releaseRevision int, valuesChecksum string) (HelmRelease, bool) {
949951
changed := hr.Status.LastAttemptedRevision != revision ||
950952
hr.Status.LastReleaseRevision != releaseRevision ||
@@ -956,6 +958,31 @@ func HelmReleaseAttempted(hr HelmRelease, revision string, releaseRevision int,
956958
return hr, changed
957959
}
958960

961+
// HelmReleaseChanged returns if the HelmRelease has changed compared to the
962+
// provided values.
963+
func HelmReleaseChanged(hr HelmRelease, revision string, releaseRevision int, valuesChecksums ...string) bool {
964+
return hr.Status.LastAttemptedRevision != revision ||
965+
hr.Status.LastReleaseRevision != releaseRevision ||
966+
!inStringSlice(hr.Status.LastAttemptedValuesChecksum, valuesChecksums)
967+
}
968+
969+
// HelmReleaseRecordAttempt returns an attempt of the given HelmRelease with the
970+
// given state in the Status of the provided object.
971+
func HelmReleaseRecordAttempt(hr *HelmRelease, revision string, releaseRevision int, valuesChecksum string) {
972+
hr.Status.LastAttemptedRevision = revision
973+
hr.Status.LastReleaseRevision = releaseRevision
974+
hr.Status.LastAttemptedValuesChecksum = valuesChecksum
975+
}
976+
977+
func inStringSlice(str string, s []string) bool {
978+
for _, v := range s {
979+
if str == v {
980+
return true
981+
}
982+
}
983+
return false
984+
}
985+
959986
func resetFailureCounts(hr *HelmRelease) {
960987
hr.Status.Failures = 0
961988
hr.Status.InstallFailures = 0

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/opencontainers/go-digest v1.0.0
2121
github.com/opencontainers/go-digest/blake3 v0.0.0-20220411205349-bde1400a84be
2222
github.com/spf13/pflag v1.0.5
23+
gopkg.in/yaml.v2 v2.4.0
2324
helm.sh/helm/v3 v3.11.3
2425
k8s.io/api v0.26.3
2526
k8s.io/apiextensions-apiserver v0.26.3
@@ -159,7 +160,6 @@ require (
159160
google.golang.org/grpc v1.53.0 // indirect
160161
google.golang.org/protobuf v1.28.1 // indirect
161162
gopkg.in/inf.v0 v0.9.1 // indirect
162-
gopkg.in/yaml.v2 v2.4.0 // indirect
163163
gopkg.in/yaml.v3 v3.0.1 // indirect
164164
k8s.io/apiserver v0.26.3 // indirect
165165
k8s.io/component-base v0.26.3 // indirect

internal/controllers/helmrelease_controller.go

+7-3
Original file line numberDiff line numberDiff line change
@@ -322,11 +322,15 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context,
322322
return v2.HelmReleaseNotReady(hr, v2.GetLastReleaseFailedReason, "failed to get last release revision"), err
323323
}
324324

325-
// Register the current release attempt.
325+
// Detect divergence between release in storage and HelmRelease spec.
326326
revision := chart.Metadata.Version
327327
releaseRevision := util.ReleaseRevision(rel)
328-
valuesChecksum := util.ValuesChecksum(values)
329-
hr, hasNewState := v2.HelmReleaseAttempted(hr, revision, releaseRevision, valuesChecksum)
328+
// TODO: deprecate "unordered" checksum.
329+
valuesChecksum := util.OrderedValuesChecksum(values)
330+
hasNewState := v2.HelmReleaseChanged(hr, revision, releaseRevision, util.ValuesChecksum(values), valuesChecksum)
331+
332+
// Register the current release attempt.
333+
v2.HelmReleaseRecordAttempt(&hr, revision, releaseRevision, valuesChecksum)
330334

331335
// Run diff against current cluster state.
332336
if !hasNewState && rel != nil {

internal/util/util.go

+78
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ package util
1919
import (
2020
"crypto/sha1"
2121
"fmt"
22+
"sort"
2223

24+
goyaml "gopkg.in/yaml.v2"
2325
"helm.sh/helm/v3/pkg/chartutil"
2426
"helm.sh/helm/v3/pkg/release"
27+
"sigs.k8s.io/yaml"
2528
)
2629

2730
// ValuesChecksum calculates and returns the SHA1 checksum for the
@@ -34,6 +37,81 @@ func ValuesChecksum(values chartutil.Values) string {
3437
return fmt.Sprintf("%x", sha1.Sum([]byte(s)))
3538
}
3639

40+
// OrderedValuesChecksum sort the chartutil.Values then calculates
41+
// and returns the SHA1 checksum for the sorted values.
42+
func OrderedValuesChecksum(values chartutil.Values) string {
43+
var s []byte
44+
if len(values) != 0 {
45+
msValues := yaml.JSONObjectToYAMLObject(copyValues(values))
46+
SortMapSlice(msValues)
47+
s, _ = goyaml.Marshal(msValues)
48+
}
49+
return fmt.Sprintf("%x", sha1.Sum(s))
50+
}
51+
52+
// SortMapSlice recursively sorts the given goyaml.MapSlice by key.
53+
// This is used to ensure that the values checksum is the same regardless
54+
// of the order of the keys in the values file.
55+
func SortMapSlice(ms goyaml.MapSlice) {
56+
sort.Slice(ms, func(i, j int) bool {
57+
return fmt.Sprint(ms[i].Key) < fmt.Sprint(ms[j].Key)
58+
})
59+
for _, item := range ms {
60+
if nestedMS, ok := item.Value.(goyaml.MapSlice); ok {
61+
SortMapSlice(nestedMS)
62+
} else if _, ok := item.Value.([]interface{}); ok {
63+
for _, vItem := range item.Value.([]interface{}) {
64+
if itemMS, ok := vItem.(goyaml.MapSlice); ok {
65+
SortMapSlice(itemMS)
66+
}
67+
}
68+
}
69+
}
70+
}
71+
72+
// cleanUpMapValue changes all instances of
73+
// map[interface{}]interface{} to map[string]interface{}.
74+
// This is for handling the mismatch when unmarshaling
75+
// reference to the issue: https://github.com/go-yaml/yaml/issues/139
76+
func cleanUpMapValue(v interface{}) interface{} {
77+
switch v := v.(type) {
78+
case []interface{}:
79+
return cleanUpInterfaceArray(v)
80+
case map[interface{}]interface{}:
81+
return cleanUpInterfaceMap(v)
82+
default:
83+
return v
84+
}
85+
}
86+
87+
func cleanUpInterfaceMap(in map[interface{}]interface{}) map[string]interface{} {
88+
result := make(map[string]interface{})
89+
for k, v := range in {
90+
result[fmt.Sprintf("%v", k)] = cleanUpMapValue(v)
91+
}
92+
return result
93+
}
94+
95+
func cleanUpInterfaceArray(in []interface{}) []interface{} {
96+
result := make([]interface{}, len(in))
97+
for i, v := range in {
98+
result[i] = cleanUpMapValue(v)
99+
}
100+
return result
101+
}
102+
103+
func copyValues(in map[string]interface{}) map[string]interface{} {
104+
copiedValues, _ := goyaml.Marshal(in)
105+
newValues := make(map[string]interface{})
106+
107+
_ = goyaml.Unmarshal(copiedValues, newValues)
108+
for i, value := range newValues {
109+
newValues[i] = cleanUpMapValue(value)
110+
}
111+
112+
return newValues
113+
}
114+
37115
// ReleaseRevision returns the revision of the given release.Release.
38116
func ReleaseRevision(rel *release.Release) int {
39117
if rel == nil {

internal/util/util_test.go

+164
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ limitations under the License.
1717
package util
1818

1919
import (
20+
"reflect"
2021
"testing"
2122

23+
goyaml "gopkg.in/yaml.v2"
2224
"helm.sh/helm/v3/pkg/chartutil"
2325
"helm.sh/helm/v3/pkg/release"
2426
)
@@ -54,6 +56,38 @@ func TestValuesChecksum(t *testing.T) {
5456
}
5557
}
5658

59+
func TestOrderedValuesChecksum(t *testing.T) {
60+
tests := []struct {
61+
name string
62+
values chartutil.Values
63+
want string
64+
}{
65+
{
66+
name: "empty",
67+
values: chartutil.Values{},
68+
want: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
69+
},
70+
{
71+
name: "value map",
72+
values: chartutil.Values{
73+
"foo": "bar",
74+
"baz": map[string]string{
75+
"fruit": "apple",
76+
"cool": "stuff",
77+
},
78+
},
79+
want: "dfd6589332e4d2da5df7bcbf5885f406f08b58ee",
80+
},
81+
}
82+
for _, tt := range tests {
83+
t.Run(tt.name, func(t *testing.T) {
84+
if got := OrderedValuesChecksum(tt.values); got != tt.want {
85+
t.Errorf("OrderedValuesChecksum() = %v, want %v", got, tt.want)
86+
}
87+
})
88+
}
89+
}
90+
5791
func TestReleaseRevision(t *testing.T) {
5892
var rel *release.Release
5993
if rev := ReleaseRevision(rel); rev != 0 {
@@ -64,3 +98,133 @@ func TestReleaseRevision(t *testing.T) {
6498
t.Fatalf("ReleaseRevision() = %v, want %v", rev, 1)
6599
}
66100
}
101+
102+
func TestSortMapSlice(t *testing.T) {
103+
tests := []struct {
104+
name string
105+
input goyaml.MapSlice
106+
expected goyaml.MapSlice
107+
}{
108+
{
109+
name: "Simple case",
110+
input: goyaml.MapSlice{
111+
{Key: "b", Value: 2},
112+
{Key: "a", Value: 1},
113+
},
114+
expected: goyaml.MapSlice{
115+
{Key: "a", Value: 1},
116+
{Key: "b", Value: 2},
117+
},
118+
},
119+
{
120+
name: "Nested MapSlice",
121+
input: goyaml.MapSlice{
122+
{Key: "b", Value: 2},
123+
{Key: "a", Value: 1},
124+
{Key: "c", Value: goyaml.MapSlice{
125+
{Key: "d", Value: 4},
126+
{Key: "e", Value: 5},
127+
}},
128+
},
129+
expected: goyaml.MapSlice{
130+
{Key: "a", Value: 1},
131+
{Key: "b", Value: 2},
132+
{Key: "c", Value: goyaml.MapSlice{
133+
{Key: "d", Value: 4},
134+
{Key: "e", Value: 5},
135+
}},
136+
},
137+
},
138+
{
139+
name: "Empty MapSlice",
140+
input: goyaml.MapSlice{},
141+
expected: goyaml.MapSlice{},
142+
},
143+
{
144+
name: "Single element",
145+
input: goyaml.MapSlice{
146+
{Key: "a", Value: 1},
147+
},
148+
expected: goyaml.MapSlice{
149+
{Key: "a", Value: 1},
150+
},
151+
},
152+
{
153+
name: "Already sorted",
154+
input: goyaml.MapSlice{
155+
{Key: "a", Value: 1},
156+
{Key: "b", Value: 2},
157+
{Key: "c", Value: 3},
158+
},
159+
expected: goyaml.MapSlice{
160+
{Key: "a", Value: 1},
161+
{Key: "b", Value: 2},
162+
{Key: "c", Value: 3},
163+
},
164+
},
165+
166+
{
167+
name: "Complex Case",
168+
input: goyaml.MapSlice{
169+
{Key: "b", Value: 2},
170+
{Key: "a", Value: map[interface{}]interface{}{
171+
"d": []interface{}{4, 5},
172+
"c": 3,
173+
}},
174+
{Key: "c", Value: goyaml.MapSlice{
175+
{Key: "f", Value: 6},
176+
{Key: "e", Value: goyaml.MapSlice{
177+
{Key: "h", Value: 8},
178+
{Key: "g", Value: 7},
179+
}},
180+
}},
181+
},
182+
expected: goyaml.MapSlice{
183+
{Key: "a", Value: map[interface{}]interface{}{
184+
"c": 3,
185+
"d": []interface{}{4, 5},
186+
}},
187+
{Key: "b", Value: 2},
188+
{Key: "c", Value: goyaml.MapSlice{
189+
{Key: "e", Value: goyaml.MapSlice{
190+
{Key: "g", Value: 7},
191+
{Key: "h", Value: 8},
192+
}},
193+
{Key: "f", Value: 6},
194+
}},
195+
},
196+
},
197+
{
198+
name: "Map slice in slice",
199+
input: goyaml.MapSlice{
200+
{Key: "b", Value: 2},
201+
{Key: "a", Value: []interface{}{
202+
map[interface{}]interface{}{
203+
"d": 4,
204+
"c": 3,
205+
},
206+
1,
207+
}},
208+
},
209+
expected: goyaml.MapSlice{
210+
{Key: "a", Value: []interface{}{
211+
map[interface{}]interface{}{
212+
"c": 3,
213+
"d": 4,
214+
},
215+
1,
216+
}},
217+
{Key: "b", Value: 2},
218+
},
219+
},
220+
}
221+
222+
for _, test := range tests {
223+
t.Run(test.name, func(t *testing.T) {
224+
SortMapSlice(test.input)
225+
if !reflect.DeepEqual(test.input, test.expected) {
226+
t.Errorf("Expected %v, got %v", test.expected, test.input)
227+
}
228+
})
229+
}
230+
}

0 commit comments

Comments
 (0)