44 "context"
55 "errors"
66 "fmt"
7+ "regexp"
78 "strings"
89
910 "helm.sh/helm/v3/pkg/release"
@@ -20,6 +21,69 @@ import (
2021 "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util"
2122)
2223
24+ // Local summary type used only by CRD upgrade safety
25+ type preflightMessageSummary struct {
26+ CheckName string
27+ Issues []string
28+ CriticalCount int
29+ BreakingCount int
30+ }
31+
32+ func newPreflightSummary () * preflightMessageSummary {
33+ return & preflightMessageSummary {CheckName : "CRD Upgrade Safety" }
34+ }
35+
36+ func (s * preflightMessageSummary ) AddCriticalIssue (issue string ) {
37+ s .CriticalCount ++
38+ s .Issues = append (s .Issues , issue )
39+ }
40+
41+ func (s * preflightMessageSummary ) AddBreakingIssue (issue string ) {
42+ s .BreakingCount ++
43+ s .Issues = append (s .Issues , issue )
44+ }
45+
46+ func (s * preflightMessageSummary ) AddIssue (issue string ) {
47+ // non-blocking (minor) still listed but not counted in totals
48+ s .Issues = append (s .Issues , issue )
49+ }
50+
51+ func (s * preflightMessageSummary ) GenerateMessage () string {
52+ total := s .CriticalCount + s .BreakingCount
53+ if total == 0 {
54+ return fmt .Sprintf ("%s\n Total: 0\n Issues: none" , s .CheckName )
55+ }
56+ header := fmt .Sprintf ("%s\n Total: %d" , s .CheckName , total )
57+ parts := []string {}
58+ if s .CriticalCount > 0 {
59+ parts = append (parts , fmt .Sprintf ("%d critical" , s .CriticalCount ))
60+ }
61+ if s .BreakingCount > 0 {
62+ parts = append (parts , fmt .Sprintf ("%d breaking" , s .BreakingCount ))
63+ }
64+ if len (parts ) > 0 {
65+ header = fmt .Sprintf ("%s (%s)" , header , strings .Join (parts , ", " ))
66+ }
67+ bullets := make ([]string , 0 , len (s .Issues ))
68+ for _ , issue := range s .Issues {
69+ bullets = append (bullets , "- " + issue )
70+ }
71+ return fmt .Sprintf ("%s\n Issues:\n %s" , header , strings .Join (bullets , "\n " ))
72+ }
73+
74+ // precompiled patterns to extract from → to details where available
75+ var (
76+ reFromToType = regexp .MustCompile (`type changed from (\S+) to (\S+)` )
77+ reDefaultChange = regexp .MustCompile (`default value changed from '([^']*)' to '([^']*)'` )
78+ reEnumTight = regexp .MustCompile (`enum constraint tightened.* from \[([^\]]*)\] to \[([^\]]*)\]` )
79+ reScopeChange = regexp .MustCompile (`scope changed from "([^"]+)" to "([^"]+)"` )
80+ reMinIncreased = regexp .MustCompile (`minimum value.* increased from ([^ ]+) to ([^ ]+)` )
81+ reMaxDecreased = regexp .MustCompile (`maximum value.* decreased from ([^ ]+) to ([^ ]+)` )
82+ reStoredRemoved = regexp .MustCompile (`stored version "?([^" ]+)"? removed` )
83+ )
84+
85+ func arrow (from , to string ) string { return fmt .Sprintf ("%s → %s" , from , to ) }
86+
2387type Option func (p * Preflight )
2488
2589func WithConfig (cfg * config.Config ) Option {
@@ -105,11 +169,8 @@ func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) erro
105169
106170 results := runner .Run (oldCrd , newCrd )
107171 if results .HasFailures () {
108- resultErrs := crdWideErrors (results )
109- resultErrs = append (resultErrs , sameVersionErrors (results )... )
110- resultErrs = append (resultErrs , servedVersionErrors (results )... )
111-
112- validateErrors = append (validateErrors , fmt .Errorf ("validating upgrade for CRD %q: %w" , newCrd .Name , errors .Join (resultErrs ... )))
172+ summary := summarizeValidationFailures (results )
173+ validateErrors = append (validateErrors , fmt .Errorf ("CRD %q upgrade blocked: %s" , newCrd .Name , summary ))
113174 }
114175 }
115176
@@ -146,55 +207,196 @@ func defaultRegistry() validations.Registry {
146207 return runner .DefaultRegistry ()
147208}
148209
149- func crdWideErrors (results * runner.Results ) []error {
210+ // summarizeValidationFailures creates a concise, meaningful summary of CRD validation failures
211+ func summarizeValidationFailures (results * runner.Results ) string {
150212 if results == nil {
151- return nil
213+ return "The OLM preflight blocked our CRD update because it isn't backwards-compatible. Please rework the change to be additive: avoid removing or tightening fields, don't change types in place, and keep defaults stable. If this is a semantic change, add a new CRD version and keep the old one served until we migrate."
152214 }
153215
154- errs := []error {}
216+ summary := newPreflightSummary ()
217+
218+ // Process CRD-wide validation errors
155219 for _ , result := range results .CRDValidation {
156220 for _ , err := range result .Errors {
157- errs = append ( errs , fmt . Errorf ( "%s: %s" , result .Name , err ) )
221+ addCategorizedIssue ( summary , err , result .Name )
158222 }
159223 }
160224
161- return errs
162- }
163-
164- func sameVersionErrors (results * runner.Results ) []error {
165- if results == nil {
166- return nil
167- }
168-
169- errs := []error {}
225+ // Process same version errors (breaking changes)
170226 for version , propertyResults := range results .SameVersionValidation {
171227 for property , comparisonResults := range propertyResults {
172228 for _ , result := range comparisonResults {
173229 for _ , err := range result .Errors {
174- errs = append (errs , fmt .Errorf ("%s: %s: %s: %s" , version , property , result .Name , err ))
230+ context := fmt .Sprintf ("%s.%s.%s" , version , property , result .Name )
231+ addCategorizedIssue (summary , err , context )
175232 }
176233 }
177234 }
178235 }
179236
180- return errs
181- }
182-
183- func servedVersionErrors (results * runner.Results ) []error {
184- if results == nil {
185- return nil
186- }
187-
188- errs := []error {}
189- for version , propertyResults := range results .ServedVersionValidation {
190- for property , comparisonResults := range propertyResults {
237+ // Process served version errors
238+ for _ , propertyResults := range results .ServedVersionValidation {
239+ for _ , comparisonResults := range propertyResults {
191240 for _ , result := range comparisonResults {
192241 for _ , err := range result .Errors {
193- errs = append (errs , fmt .Errorf ("%s: %s: %s: %s" , version , property , result .Name , err ))
242+ context := fmt .Sprintf ("served version: %s" , result .Name )
243+ addCategorizedIssue (summary , err , context )
194244 }
195245 }
196246 }
197247 }
198248
199- return errs
249+ // Compute per-severity counts from finalized issue messages
250+ var criticalCount , breakingCount , otherCount int
251+ for _ , issue := range summary .Issues {
252+ l := strings .ToLower (issue )
253+ switch {
254+ // critical
255+ case strings .HasPrefix (l , "field removal detected" ):
256+ criticalCount ++
257+ case strings .HasPrefix (l , "required field added" ):
258+ criticalCount ++
259+ case strings .HasPrefix (l , "version removal/scope change" ):
260+ criticalCount ++
261+ // breaking
262+ case strings .HasPrefix (l , "type changed" ):
263+ breakingCount ++
264+ case strings .HasPrefix (l , "enum restriction tightened" ), strings .HasPrefix (l , "enum restriction added" ):
265+ breakingCount ++
266+ case strings .HasPrefix (l , "default changed" ), strings .HasPrefix (l , "default added" ), strings .HasPrefix (l , "default removed" ):
267+ breakingCount ++
268+ case strings .HasPrefix (l , "minimum increased" ), strings .HasPrefix (l , "maximum decreased" ), strings .HasPrefix (l , "constraint added" ):
269+ breakingCount ++
270+ default :
271+ otherCount ++
272+ }
273+ }
274+ total := len (summary .Issues )
275+ if total == 0 {
276+ return "CRD Upgrade Safety\n Total: 0\n Issues: none"
277+ }
278+ parts := []string {}
279+ if criticalCount > 0 {
280+ parts = append (parts , fmt .Sprintf ("%d critical" , criticalCount ))
281+ }
282+ if breakingCount > 0 {
283+ parts = append (parts , fmt .Sprintf ("%d breaking" , breakingCount ))
284+ }
285+ if otherCount > 0 {
286+ parts = append (parts , fmt .Sprintf ("%d other" , otherCount ))
287+ }
288+ header := fmt .Sprintf ("CRD Upgrade Safety\n Total: %d (%s)" , total , strings .Join (parts , ", " ))
289+ bullets := make ([]string , 0 , total )
290+ for _ , issue := range summary .Issues {
291+ bullets = append (bullets , "- " + issue )
292+ }
293+ return fmt .Sprintf ("%s\n Issues:\n %s" , header , strings .Join (bullets , "\n " ))
294+ }
295+
296+ // addCategorizedIssue categorizes an error and adds it to the summary with appropriate severity
297+ func addCategorizedIssue (summary * preflightMessageSummary , errStr , context string ) {
298+ message := categorizeValidationError (errStr , context )
299+ summary .AddIssue (message )
300+ }
301+
302+ // determineErrorSeverity determines if an error is critical, breaking, or minor based on its content
303+ func determineErrorSeverity (errStr string ) string {
304+ lowerErr := strings .ToLower (errStr )
305+ // Critical: deletions/required/version/scope
306+ if strings .Contains (lowerErr , "existing field" ) && strings .Contains (lowerErr , "removed" ) {
307+ return "critical"
308+ }
309+ if strings .Contains (lowerErr , "required" ) && (strings .Contains (lowerErr , "added" ) || strings .Contains (lowerErr , "new" )) {
310+ return "critical"
311+ }
312+ if strings .Contains (lowerErr , "stored version" ) && strings .Contains (lowerErr , "removed" ) {
313+ return "critical"
314+ }
315+ if strings .Contains (lowerErr , "served version" ) && strings .Contains (lowerErr , "removed" ) {
316+ return "critical"
317+ }
318+ if strings .Contains (lowerErr , "scope changed" ) {
319+ return "critical"
320+ }
321+ // Breaking: type/enums/defaults/constraints
322+ if strings .Contains (lowerErr , "type changed" ) || (strings .Contains (lowerErr , "type" ) && strings .Contains (lowerErr , "changed" )) {
323+ return "breaking"
324+ }
325+ if strings .Contains (lowerErr , "enum constraint tightened" ) || (strings .Contains (lowerErr , "enum" ) && (strings .Contains (lowerErr , "removed" ) || strings .Contains (lowerErr , "restricted" ))) {
326+ return "breaking"
327+ }
328+ if strings .Contains (lowerErr , "default value changed" ) || (strings .Contains (lowerErr , "default" ) && (strings .Contains (lowerErr , "changed" ) || strings .Contains (lowerErr , "added" ) || strings .Contains (lowerErr , "removed" ))) {
329+ return "breaking"
330+ }
331+ if (strings .Contains (lowerErr , "minimum" ) || strings .Contains (lowerErr , "maximum" ) || strings .Contains (lowerErr , "minlength" ) || strings .Contains (lowerErr , "maxlength" ) || strings .Contains (lowerErr , "minitems" ) || strings .Contains (lowerErr , "maxitems" )) && (strings .Contains (lowerErr , "increased" ) || strings .Contains (lowerErr , "decreased" ) || strings .Contains (lowerErr , "added" )) {
332+ return "breaking"
333+ }
334+ return "minor"
335+ }
336+
337+ // categorizeValidationError provides specific, actionable messages based on the type of CRD validation failure
338+ func categorizeValidationError (errStr , context string ) string {
339+ lowerErr := strings .ToLower (errStr )
340+
341+ // Version/scope changes (check first to avoid false matches)
342+ if m := reStoredRemoved .FindStringSubmatch (lowerErr ); len (m ) == 2 {
343+ return fmt .Sprintf ("Version removal/scope change (%s): stored version removed (%s)" , context , m [1 ])
344+ }
345+ if m := reScopeChange .FindStringSubmatch (lowerErr ); len (m ) == 3 {
346+ return fmt .Sprintf ("Version removal/scope change (%s): scope %s" , context , arrow (m [1 ], m [2 ]))
347+ }
348+ if (strings .Contains (lowerErr , "stored version" ) || strings .Contains (lowerErr , "served version" )) && strings .Contains (lowerErr , "removed" ) {
349+ return fmt .Sprintf ("Version removal/scope change (%s): stored/served version removed" , context )
350+ }
351+
352+ // Required field addition
353+ if strings .Contains (lowerErr , "required" ) && (strings .Contains (lowerErr , "added" ) || strings .Contains (lowerErr , "new" )) {
354+ return fmt .Sprintf ("Required field added (%s): Make the new field optional or provide a default. Required-field additions break existing CRs and are rejected by OLM's safety check." , context )
355+ }
356+
357+ // Field removal
358+ if strings .Contains (lowerErr , "removal" ) || strings .Contains (lowerErr , "removed" ) || strings .Contains (lowerErr , "existing field" ) {
359+ return fmt .Sprintf ("Field removal detected (%s): The OLM preflight blocked our CRD update because it isn't backwards-compatible. Please rework the change to be additive: avoid removing fields." , context )
360+ }
361+
362+ // Enum/range tightening
363+ if m := reEnumTight .FindStringSubmatch (lowerErr ); len (m ) == 3 {
364+ return fmt .Sprintf ("Enum restriction tightened (%s): %s. Avoid narrowing enums; only additive relaxations are allowed." , context , arrow (m [1 ], m [2 ]))
365+ }
366+ if strings .Contains (lowerErr , "enum values removed" ) || (strings .Contains (lowerErr , "enum" ) && strings .Contains (lowerErr , "removed" )) || strings .Contains (lowerErr , "enum restriction added" ) {
367+ return fmt .Sprintf ("Enum restriction added (%s): Avoid adding new enum restrictions or removing existing enum values." , context )
368+ }
369+
370+ // Default value changes
371+ if m := reDefaultChange .FindStringSubmatch (lowerErr ); len (m ) == 3 {
372+ return fmt .Sprintf ("Default changed (%s): %s. Keep the old default, or introduce the new behavior via a new field or version." , context , arrow (m [1 ], m [2 ]))
373+ }
374+ if (strings .Contains (lowerErr , "default" ) && strings .Contains (lowerErr , "added" )) || strings .Contains (lowerErr , "default value added" ) {
375+ return fmt .Sprintf ("Default added (%s): Adding a new default may change existing behavior. Prefer introducing a new field or version." , context )
376+ }
377+ if (strings .Contains (lowerErr , "default" ) && strings .Contains (lowerErr , "removed" )) || strings .Contains (lowerErr , "default value removed" ) {
378+ return fmt .Sprintf ("Default removed (%s): Removing a default may break existing behavior. Keep the existing default or introduce a new field." , context )
379+ }
380+
381+ // Type changes
382+ if m := reFromToType .FindStringSubmatch (lowerErr ); len (m ) == 3 {
383+ return fmt .Sprintf ("Type changed (%s): %s. The OLM preflight blocked our CRD update because it isn't backwards-compatible. Don't change types in place - add a new CRD version instead." , context , arrow (m [1 ], m [2 ]))
384+ }
385+ if strings .Contains (lowerErr , "type" ) && (strings .Contains (lowerErr , "changed" ) || strings .Contains (lowerErr , "different" )) {
386+ return fmt .Sprintf ("Type changed (%s): The OLM preflight blocked our CRD update because it isn't backwards-compatible. Don't change types in place - add a new CRD version instead." , context )
387+ }
388+
389+ // Numeric constraints
390+ if m := reMinIncreased .FindStringSubmatch (lowerErr ); len (m ) == 3 {
391+ return fmt .Sprintf ("Minimum increased (%s): %s. Increasing minimums is prohibited; only decreases are allowed." , context , arrow (m [1 ], m [2 ]))
392+ }
393+ if m := reMaxDecreased .FindStringSubmatch (lowerErr ); len (m ) == 3 {
394+ return fmt .Sprintf ("Maximum decreased (%s): %s. Decreasing maximums is prohibited; only increases are allowed." , context , arrow (m [1 ], m [2 ]))
395+ }
396+ if (strings .Contains (lowerErr , "minimum" ) || strings .Contains (lowerErr , "maximum" ) || strings .Contains (lowerErr , "minlength" ) || strings .Contains (lowerErr , "maxlength" ) || strings .Contains (lowerErr , "minitems" ) || strings .Contains (lowerErr , "maxitems" )) && strings .Contains (lowerErr , "added" ) {
397+ return fmt .Sprintf ("Constraint added (%s): Adding min/max constraints to previously unconstrained fields is prohibited." , context )
398+ }
399+
400+ // Generic backwards-compatibility failure
401+ return fmt .Sprintf ("Backwards-compatibility issue (%s): The OLM preflight blocked our CRD update because it isn't backwards-compatible. Please rework the change to be additive: avoid removing or tightening fields, don't change types in place, and keep defaults stable." , context )
200402}
0 commit comments