Skip to content

Commit 0845f38

Browse files
committed
add orderKappsValidateErr in crdupgradesafety preflight
orderKappsValidateErr() is meant as a temporary solution to an external (ie. dependency) problem. carvel.dev/kapp/pkg/kapp/crdupgradesafety Validate() can return a multi-line error message which comes in random order. Until that is changed upstream, we need to fix this on our side to avoid falling into cycle of constantly trying to reconcile ClusterExtension's status due to random error message we set in its conditions.
1 parent 594cba3 commit 0845f38

File tree

2 files changed

+143
-1
lines changed

2 files changed

+143
-1
lines changed

internal/rukpak/preflights/crdupgradesafety/checks_test.go

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

33
import (
44
"errors"
5+
"fmt"
56
"testing"
67

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

internal/rukpak/preflights/crdupgradesafety/crdupgradesafety.go

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

33
import (
4+
"cmp"
45
"context"
56
"errors"
67
"fmt"
8+
"slices"
79
"strings"
810

911
kappcus "carvel.dev/kapp/pkg/kapp/crdupgradesafety"
@@ -84,7 +86,7 @@ func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) erro
8486
return fmt.Errorf("parsing release %q objects: %w", rel.Name, err)
8587
}
8688

87-
validateErrors := []error{}
89+
validateErrors := make([]error, 0, len(relObjects))
8890
for _, obj := range relObjects {
8991
if obj.GetObjectKind().GroupVersionKind() != apiextensionsv1.SchemeGroupVersion.WithKind("CustomResourceDefinition") {
9092
continue
@@ -112,9 +114,70 @@ func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) erro
112114

113115
err = p.validator.Validate(*oldCrd, *newCrd)
114116
if err != nil {
117+
err = orderKappsValidateErr(err)
115118
validateErrors = append(validateErrors, fmt.Errorf("validating upgrade for CRD %q failed: %w", newCrd.Name, err))
116119
}
117120
}
118121

119122
return errors.Join(validateErrors...)
120123
}
124+
125+
// orderKappsValidateErr is meant as a temporary solution to the problem
126+
// of randomly ordered multi-line validation error returned by kapp's validator.Validate()
127+
//
128+
// The problem is that kapp's field validations are performed in map iteration order, which is not fixed.
129+
// Errors from those validations are then error.Join'ed, fmt.Errorf'ed and error.Join'ed again,
130+
// which means original messages are available at 3rd level of nesting, and this is where we need to
131+
// sort them to ensure we do not enter into constant reconciliation loop because of random order of
132+
// failure message we ultimately set in ClusterExtension's status conditions.
133+
//
134+
// This helper attempts to do that and falls back to original unchanged error message
135+
// in case of any unforseen issues which likely mean that the internals of validator.Validate
136+
// have changed.
137+
//
138+
// For full context see:
139+
// github.com/operator-framework/operator-controller/issues/1456 (original issue and comments)
140+
// github.com/carvel-dev/kapp/pull/1047 (PR to ensure order in upstream)
141+
//
142+
// TODO: remove this once ordering has been handled by the upstream.
143+
func orderKappsValidateErr(err error) error {
144+
joinedValidationErrs, ok := err.(interface{ Unwrap() []error })
145+
if !ok {
146+
return err
147+
}
148+
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)