Skip to content

Commit e8c281e

Browse files
authored
🐛 Ensure fixed order in multi-line errors returned by crdupgradesafety validators (#1864)
* fix: ensure order in multi-line errors returned by crdupgradesafety validators * chore: remove redundant orderKappsValidateErr * refactor: extract ServedVersionValidator to its own file --------- Co-authored-by: Artur Zych <[email protected]>
1 parent 416fbdc commit e8c281e

File tree

7 files changed

+287
-214
lines changed

7 files changed

+287
-214
lines changed

internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ package crdupgradesafety
88
import (
99
"errors"
1010
"fmt"
11+
"maps"
1112
"reflect"
13+
"slices"
1214

1315
"github.com/openshift/crd-schema-checker/pkg/manifestcomparators"
1416
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@@ -63,7 +65,9 @@ func (cv *ChangeValidator) Validate(old, new apiextensionsv1.CustomResourceDefin
6365
continue
6466
}
6567

66-
for field, diff := range diffs {
68+
for _, field := range slices.Sorted(maps.Keys(diffs)) {
69+
diff := diffs[field]
70+
6771
handled := false
6872
for _, validation := range cv.Validations {
6973
ok, err := validation(diff)

internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator_test.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package crdupgradesafety_test
77

88
import (
99
"errors"
10+
"fmt"
1011
"testing"
1112

1213
"github.com/stretchr/testify/assert"
@@ -130,12 +131,15 @@ func TestFlattenSchema(t *testing.T) {
130131
}
131132

132133
func TestChangeValidator(t *testing.T) {
134+
validationErr1 := errors.New(`version "v1alpha1", field "^" has unknown change, refusing to determine that change is safe`)
135+
validationErr2 := errors.New(`version "v1alpha1", field "^": fail`)
136+
133137
for _, tc := range []struct {
134138
name string
135139
changeValidator *crdupgradesafety.ChangeValidator
136140
old apiextensionsv1.CustomResourceDefinition
137141
new apiextensionsv1.CustomResourceDefinition
138-
shouldError bool
142+
expectedError error
139143
}{
140144
{
141145
name: "no changes, no error",
@@ -242,7 +246,7 @@ func TestChangeValidator(t *testing.T) {
242246
},
243247
},
244248
},
245-
shouldError: true,
249+
expectedError: validationErr1,
246250
},
247251
{
248252
name: "changes, validation failed, change fully handled, error",
@@ -279,15 +283,18 @@ func TestChangeValidator(t *testing.T) {
279283
},
280284
},
281285
},
282-
shouldError: true,
286+
expectedError: validationErr2,
283287
},
284288
{
285-
name: "changes, validation failed, change not fully handled, error",
289+
name: "changes, validation failed, change not fully handled, ordered error",
286290
changeValidator: &crdupgradesafety.ChangeValidator{
287291
Validations: []crdupgradesafety.ChangeValidation{
288292
func(_ crdupgradesafety.FieldDiff) (bool, error) {
289293
return false, errors.New("fail")
290294
},
295+
func(_ crdupgradesafety.FieldDiff) (bool, error) {
296+
return false, errors.New("error")
297+
},
291298
},
292299
},
293300
old: apiextensionsv1.CustomResourceDefinition{
@@ -316,12 +323,16 @@ func TestChangeValidator(t *testing.T) {
316323
},
317324
},
318325
},
319-
shouldError: true,
326+
expectedError: fmt.Errorf("%w\n%s\n%w", validationErr2, `version "v1alpha1", field "^": error`, validationErr1),
320327
},
321328
} {
322329
t.Run(tc.name, func(t *testing.T) {
323330
err := tc.changeValidator.Validate(tc.old, tc.new)
324-
assert.Equal(t, tc.shouldError, err != nil, "should error? - %v", tc.shouldError)
331+
if tc.expectedError != nil {
332+
assert.EqualError(t, err, tc.expectedError.Error())
333+
} else {
334+
assert.NoError(t, err)
335+
}
325336
})
326337
}
327338
}

internal/operator-controller/rukpak/preflights/crdupgradesafety/checks.go

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,77 +3,13 @@ package crdupgradesafety
33
import (
44
"bytes"
55
"cmp"
6-
"errors"
76
"fmt"
87
"reflect"
9-
"slices"
108

119
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1210
"k8s.io/apimachinery/pkg/util/sets"
13-
versionhelper "k8s.io/apimachinery/pkg/version"
1411
)
1512

16-
type ServedVersionValidator struct {
17-
Validations []ChangeValidation
18-
}
19-
20-
func (c *ServedVersionValidator) Validate(old, new apiextensionsv1.CustomResourceDefinition) error {
21-
// If conversion webhook is specified, pass check
22-
if new.Spec.Conversion != nil && new.Spec.Conversion.Strategy == apiextensionsv1.WebhookConverter {
23-
return nil
24-
}
25-
26-
errs := []error{}
27-
servedVersions := []apiextensionsv1.CustomResourceDefinitionVersion{}
28-
for _, version := range new.Spec.Versions {
29-
if version.Served {
30-
servedVersions = append(servedVersions, version)
31-
}
32-
}
33-
34-
slices.SortFunc(servedVersions, func(a, b apiextensionsv1.CustomResourceDefinitionVersion) int {
35-
return versionhelper.CompareKubeAwareVersionStrings(a.Name, b.Name)
36-
})
37-
38-
for i, oldVersion := range servedVersions[:len(servedVersions)-1] {
39-
for _, newVersion := range servedVersions[i+1:] {
40-
flatOld := FlattenSchema(oldVersion.Schema.OpenAPIV3Schema)
41-
flatNew := FlattenSchema(newVersion.Schema.OpenAPIV3Schema)
42-
diffs, err := CalculateFlatSchemaDiff(flatOld, flatNew)
43-
if err != nil {
44-
errs = append(errs, fmt.Errorf("calculating schema diff between CRD versions %q and %q", oldVersion.Name, newVersion.Name))
45-
continue
46-
}
47-
48-
for field, diff := range diffs {
49-
handled := false
50-
for _, validation := range c.Validations {
51-
ok, err := validation(diff)
52-
if err != nil {
53-
errs = append(errs, fmt.Errorf("version upgrade %q to %q, field %q: %w", oldVersion.Name, newVersion.Name, field, err))
54-
}
55-
if ok {
56-
handled = true
57-
break
58-
}
59-
}
60-
61-
if !handled {
62-
errs = append(errs, fmt.Errorf("version %q, field %q has unknown change, refusing to determine that change is safe", oldVersion.Name, field))
63-
}
64-
}
65-
}
66-
}
67-
if len(errs) > 0 {
68-
return errors.Join(errs...)
69-
}
70-
return nil
71-
}
72-
73-
func (c *ServedVersionValidator) Name() string {
74-
return "ServedVersionValidator"
75-
}
76-
7713
type resetFunc func(diff FieldDiff) FieldDiff
7814

7915
func isHandled(diff FieldDiff, reset resetFunc) bool {

internal/operator-controller/rukpak/preflights/crdupgradesafety/checks_test.go

Lines changed: 0 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package crdupgradesafety
22

33
import (
44
"errors"
5-
"fmt"
65
"testing"
76

87
"github.com/stretchr/testify/require"
@@ -905,81 +904,3 @@ func TestType(t *testing.T) {
905904
})
906905
}
907906
}
908-
909-
func TestOrderKappsValidateErr(t *testing.T) {
910-
testErr1 := errors.New("fallback1")
911-
testErr2 := errors.New("fallback2")
912-
913-
generateErrors := func(n int, base string) []error {
914-
var result []error
915-
for i := n; i >= 0; i-- {
916-
result = append(result, fmt.Errorf("%s%d", base, i))
917-
}
918-
return result
919-
}
920-
921-
joinedAndNested := func(format string, errs ...error) error {
922-
return fmt.Errorf(format, errors.Join(errs...))
923-
}
924-
925-
testCases := []struct {
926-
name string
927-
inpuError error
928-
expectedError error
929-
}{
930-
{
931-
name: "fallback: initial error was not error.Join'ed",
932-
inpuError: testErr1,
933-
expectedError: testErr1,
934-
},
935-
{
936-
name: "fallback: nested error was not wrapped",
937-
inpuError: errors.Join(testErr1),
938-
expectedError: testErr1,
939-
},
940-
{
941-
name: "fallback: multiple nested errors, one was not wrapped",
942-
inpuError: errors.Join(testErr2, fmt.Errorf("%w", testErr1)),
943-
expectedError: errors.Join(testErr2, fmt.Errorf("%w", testErr1)),
944-
},
945-
{
946-
name: "fallback: nested error did not contain \":\"",
947-
inpuError: errors.Join(fmt.Errorf("%w", testErr1)),
948-
expectedError: testErr1,
949-
},
950-
{
951-
name: "fallback: multiple nested errors, one did not contain \":\"",
952-
inpuError: errors.Join(joinedAndNested("fail: %w", testErr2), joinedAndNested("%w", testErr1)),
953-
expectedError: errors.Join(fmt.Errorf("fail: %w", testErr2), testErr1),
954-
},
955-
{
956-
name: "fallback: nested error was not error.Join'ed",
957-
inpuError: errors.Join(fmt.Errorf("fail: %w", testErr1)),
958-
expectedError: fmt.Errorf("fail: %w", testErr1),
959-
},
960-
{
961-
name: "fallback: multiple nested errors, one was not error.Join'ed",
962-
inpuError: errors.Join(joinedAndNested("fail: %w", testErr2), fmt.Errorf("fail: %w", testErr1)),
963-
expectedError: fmt.Errorf("fail: %w\nfail: %w", testErr2, testErr1),
964-
},
965-
{
966-
name: "ensures order for a single group of multiple deeply nested errors",
967-
inpuError: errors.Join(joinedAndNested("fail: %w", testErr2, testErr1)),
968-
expectedError: fmt.Errorf("fail: %w\n%w", testErr1, testErr2),
969-
},
970-
{
971-
name: "ensures order for multiple groups of deeply nested errors",
972-
inpuError: errors.Join(
973-
joinedAndNested("fail: %w", testErr2, testErr1),
974-
joinedAndNested("validation: %w", generateErrors(5, "err")...),
975-
),
976-
expectedError: fmt.Errorf("fail: %w\n%w\nvalidation: err0\nerr1\nerr2\nerr3\nerr4\nerr5", testErr1, testErr2),
977-
},
978-
}
979-
for _, tc := range testCases {
980-
t.Run(tc.name, func(t *testing.T) {
981-
err := orderKappsValidateErr(tc.inpuError)
982-
require.EqualError(t, err, tc.expectedError.Error())
983-
})
984-
}
985-
}

internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
package crdupgradesafety
22

33
import (
4-
"cmp"
54
"context"
65
"errors"
76
"fmt"
8-
"slices"
97
"strings"
108

119
"helm.sh/helm/v3/pkg/release"
@@ -113,71 +111,9 @@ func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) erro
113111

114112
err = p.validator.Validate(*oldCrd, *newCrd)
115113
if err != nil {
116-
err = orderKappsValidateErr(err)
117114
validateErrors = append(validateErrors, fmt.Errorf("validating upgrade for CRD %q failed: %w", newCrd.Name, err))
118115
}
119116
}
120117

121118
return errors.Join(validateErrors...)
122119
}
123-
124-
// orderKappsValidateErr is meant as a temporary solution to the problem
125-
// of randomly ordered multi-line validation error returned by kapp's validator.Validate()
126-
//
127-
// The problem is that kapp's field validations are performed in map iteration order, which is not fixed.
128-
// Errors from those validations are then error.Join'ed, fmt.Errorf'ed and error.Join'ed again,
129-
// which means original messages are available at 3rd level of nesting, and this is where we need to
130-
// sort them to ensure we do not enter into constant reconciliation loop because of random order of
131-
// failure message we ultimately set in ClusterExtension's status conditions.
132-
//
133-
// This helper attempts to do that and falls back to original unchanged error message
134-
// in case of any unforeseen issues which likely mean that the internals of validator.Validate
135-
// have changed.
136-
//
137-
// For full context see:
138-
// github.com/operator-framework/operator-controller/issues/1456 (original issue and comments)
139-
// github.com/carvel-dev/kapp/pull/1047 (PR to ensure order in upstream)
140-
//
141-
// TODO: remove this once ordering has been handled by the upstream.
142-
func orderKappsValidateErr(err error) error {
143-
joinedValidationErrs, ok := err.(interface{ Unwrap() []error })
144-
if !ok {
145-
return err
146-
}
147-
148-
// nolint: prealloc
149-
var errs []error
150-
for _, validationErr := range joinedValidationErrs.Unwrap() {
151-
unwrappedValidationErr := errors.Unwrap(validationErr)
152-
// validator.Validate did not error.Join'ed validation errors
153-
// kapp's internals changed - fallback to original error
154-
if unwrappedValidationErr == nil {
155-
return err
156-
}
157-
158-
prefix, _, ok := strings.Cut(validationErr.Error(), ":")
159-
// kapp's internal error format changed - fallback to original error
160-
if !ok {
161-
return err
162-
}
163-
164-
// attempt to unwrap and sort field errors
165-
joinedFieldErrs, ok := unwrappedValidationErr.(interface{ Unwrap() []error })
166-
// ChangeValidator did not error.Join'ed field validation errors
167-
// kapp's internals changed - fallback to original error
168-
if !ok {
169-
return err
170-
}
171-
172-
// ensure order of the field validation errors
173-
unwrappedFieldErrs := joinedFieldErrs.Unwrap()
174-
slices.SortFunc(unwrappedFieldErrs, func(a, b error) int {
175-
return cmp.Compare(a.Error(), b.Error())
176-
})
177-
178-
// re-join the sorted field errors keeping the original error prefix from kapp
179-
errs = append(errs, fmt.Errorf("%s: %w", prefix, errors.Join(unwrappedFieldErrs...)))
180-
}
181-
182-
return errors.Join(errs...)
183-
}

0 commit comments

Comments
 (0)