Skip to content

Commit f14e9d0

Browse files
authored
add orderKappsValidateErr in crdupgradesafety preflight (#1640)
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. Co-authored-by: Artur Zych <[email protected]>
1 parent 3f2cb85 commit f14e9d0

File tree

2 files changed

+144
-1
lines changed

2 files changed

+144
-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: 65 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,71 @@ 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 unforeseen 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+
// nolint: prealloc
150+
var errs []error
151+
for _, validationErr := range joinedValidationErrs.Unwrap() {
152+
unwrappedValidationErr := errors.Unwrap(validationErr)
153+
// validator.Validate did not error.Join'ed validation errors
154+
// kapp's internals changed - fallback to original error
155+
if unwrappedValidationErr == nil {
156+
return err
157+
}
158+
159+
prefix, _, ok := strings.Cut(validationErr.Error(), ":")
160+
// kapp's internal error format changed - fallback to original error
161+
if !ok {
162+
return err
163+
}
164+
165+
// attempt to unwrap and sort field errors
166+
joinedFieldErrs, ok := unwrappedValidationErr.(interface{ Unwrap() []error })
167+
// ChangeValidator did not error.Join'ed field validation errors
168+
// kapp's internals changed - fallback to original error
169+
if !ok {
170+
return err
171+
}
172+
173+
// ensure order of the field validation errors
174+
unwrappedFieldErrs := joinedFieldErrs.Unwrap()
175+
slices.SortFunc(unwrappedFieldErrs, func(a, b error) int {
176+
return cmp.Compare(a.Error(), b.Error())
177+
})
178+
179+
// re-join the sorted field errors keeping the original error prefix from kapp
180+
errs = append(errs, fmt.Errorf("%s: %w", prefix, errors.Join(unwrappedFieldErrs...)))
181+
}
182+
183+
return errors.Join(errs...)
184+
}

0 commit comments

Comments
 (0)