4
4
"context"
5
5
"errors"
6
6
"fmt"
7
+ "regexp"
7
8
"strings"
8
9
9
10
"helm.sh/helm/v3/pkg/release"
@@ -20,6 +21,69 @@ import (
20
21
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util"
21
22
)
22
23
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
+
23
87
type Option func (p * Preflight )
24
88
25
89
func WithConfig (cfg * config.Config ) Option {
@@ -105,11 +169,8 @@ func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) erro
105
169
106
170
results := runner .Run (oldCrd , newCrd )
107
171
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 ))
113
174
}
114
175
}
115
176
@@ -146,55 +207,196 @@ func defaultRegistry() validations.Registry {
146
207
return runner .DefaultRegistry ()
147
208
}
148
209
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 {
150
212
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."
152
214
}
153
215
154
- errs := []error {}
216
+ summary := newPreflightSummary ()
217
+
218
+ // Process CRD-wide validation errors
155
219
for _ , result := range results .CRDValidation {
156
220
for _ , err := range result .Errors {
157
- errs = append ( errs , fmt . Errorf ( "%s: %s" , result .Name , err ) )
221
+ addCategorizedIssue ( summary , err , result .Name )
158
222
}
159
223
}
160
224
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)
170
226
for version , propertyResults := range results .SameVersionValidation {
171
227
for property , comparisonResults := range propertyResults {
172
228
for _ , result := range comparisonResults {
173
229
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 )
175
232
}
176
233
}
177
234
}
178
235
}
179
236
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 {
191
240
for _ , result := range comparisonResults {
192
241
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 )
194
244
}
195
245
}
196
246
}
197
247
}
198
248
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 )
200
402
}
0 commit comments