Skip to content

Commit f8f409a

Browse files
crdupgradesafety: pretty status summary (Total + bullets), local summary type, tests
1 parent d5340da commit f8f409a

File tree

3 files changed

+424
-345
lines changed

3 files changed

+424
-345
lines changed

internal/operator-controller/controllers/common_controller.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,9 @@ import (
2828
)
2929

3030
const (
31-
// maxConditionMessageLength is the Kubernetes API limit (32768) minus truncation suffix length (25 characters)
32-
// This ensures final message with suffix never exceeds the API limit: 32768 - 25 = 32743
33-
maxConditionMessageLength = 32743
34-
// truncationSuffix is added when messages are too long
31+
// maxConditionMessageLength set the max message length allowed by Kubernetes.
32+
maxConditionMessageLength = 32768
33+
// truncationSuffix is the suffix added when a message is cut.
3534
truncationSuffix = "\n\n... [message truncated]"
3635
)
3736

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

Lines changed: 234 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
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\nTotal: 0\nIssues: none", s.CheckName)
55+
}
56+
header := fmt.Sprintf("%s\nTotal: %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\nIssues:\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+
2387
type Option func(p *Preflight)
2488

2589
func 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\nTotal: 0\nIssues: 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\nTotal: %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\nIssues:\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

Comments
 (0)