Skip to content

Commit 5b36d85

Browse files
Fix deprecation conditions leaking install errors
- stop copying install/validation errors into deprecation conditions - base bundle deprecation on the installed bundle (Unknown when none) - extend unit tests to cover resolver failures with catalog deprecations Assisted-by: Cursor
1 parent 18142b3 commit 5b36d85

File tree

6 files changed

+401
-110
lines changed

6 files changed

+401
-110
lines changed

api/v1/clusterextension_types.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -483,12 +483,12 @@ type ClusterExtensionStatus struct {
483483
// When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
484484
// When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
485485
//
486-
// When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
486+
// When the ClusterExtension is sourced from a catalog, it surfaces deprecation conditions based on catalog metadata.
487487
// These are indications from a package owner to guide users away from a particular package, channel, or bundle.
488-
// BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
489-
// ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
490-
// PackageDeprecated is set if the requested package is marked deprecated in the catalog.
491-
// Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
488+
// PackageDeprecated becomes True when the catalog marks the requested package deprecated; otherwise it stays False.
489+
// ChannelDeprecated becomes True when any requested channel is marked deprecated; otherwise it stays False.
490+
// BundleDeprecated reports the catalog status of the installed bundle: it remains Unknown until a bundle installs, then becomes True or False depending on whether the catalog marks that bundle deprecated.
491+
// Deprecated is a rollup that mirrors True whenever any of the specific deprecation conditions are True.
492492
//
493493
// +listType=map
494494
// +listMapKey=type

internal/operator-controller/controllers/clusterextension_admission_test.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@ import (
1313
)
1414

1515
func TestClusterExtensionSourceConfig(t *testing.T) {
16-
sourceTypeEmptyError := "Invalid value: null"
16+
sourceTypeEmptyErrors := []string{"Invalid value: \"null\"", "Invalid value: null"}
1717
sourceTypeMismatchError := "spec.source.sourceType: Unsupported value"
1818
sourceConfigInvalidError := "spec.source: Invalid value"
1919
// unionField represents the required Catalog or (future) Bundle field required by SourceConfig
2020
testCases := []struct {
2121
name string
2222
sourceType string
2323
unionField string
24-
errMsg string
24+
errMsgs []string
2525
}{
26-
{"sourceType is null", "", "Catalog", sourceTypeEmptyError},
27-
{"sourceType is invalid", "Invalid", "Catalog", sourceTypeMismatchError},
28-
{"catalog field does not exist", "Catalog", "", sourceConfigInvalidError},
29-
{"sourceConfig has required fields", "Catalog", "Catalog", ""},
26+
{"sourceType is null", "", "Catalog", sourceTypeEmptyErrors},
27+
{"sourceType is invalid", "Invalid", "Catalog", []string{sourceTypeMismatchError}},
28+
{"catalog field does not exist", "Catalog", "", []string{sourceConfigInvalidError}},
29+
{"sourceConfig has required fields", "Catalog", "Catalog", nil},
3030
}
3131

3232
t.Parallel()
@@ -62,12 +62,20 @@ func TestClusterExtensionSourceConfig(t *testing.T) {
6262
}))
6363
}
6464

65-
if tc.errMsg == "" {
65+
if len(tc.errMsgs) == 0 {
6666
require.NoError(t, err, "unexpected error for sourceType %q: %w", tc.sourceType, err)
67-
} else {
68-
require.Error(t, err)
69-
require.Contains(t, err.Error(), tc.errMsg)
67+
return
68+
}
69+
70+
require.Error(t, err)
71+
matched := false
72+
for _, msg := range tc.errMsgs {
73+
if strings.Contains(err.Error(), msg) {
74+
matched = true
75+
break
76+
}
7077
}
78+
require.True(t, matched, "expected one of %v in error %q", tc.errMsgs, err)
7179
})
7280
}
7381
}

internal/operator-controller/controllers/clusterextension_controller.go

Lines changed: 162 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"slices"
2626
"strings"
2727

28+
bsemver "github.com/blang/semver/v4"
2829
"github.com/go-logr/logr"
2930
"helm.sh/helm/v3/pkg/release"
3031
"helm.sh/helm/v3/pkg/storage/driver"
@@ -139,13 +140,23 @@ func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Req
139140
return res, reconcileErr
140141
}
141142

142-
// ensureAllConditionsWithReason checks that all defined condition types exist in the given ClusterExtension,
143-
// and assigns a specified reason and custom message to any missing condition.
144-
func ensureAllConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.ConditionReason, message string) {
143+
// ensureFailureConditionsWithReason keeps every non-deprecation condition present.
144+
// If one is missing, we add it with the given reason and message so users see why
145+
// reconcile failed. Deprecation conditions are handled later by SetDeprecationStatus.
146+
func ensureFailureConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.ConditionReason, message string) {
145147
for _, condType := range conditionsets.ConditionTypes {
148+
if isDeprecationCondition(condType) {
149+
continue
150+
}
146151
cond := apimeta.FindStatusCondition(ext.Status.Conditions, condType)
152+
// Guard so we only fill empty slots.
153+
// GIVEN richer helpers keep existing conditions up to date.
154+
// WHEN we skip this guard, each run would overwrite their real status with the fallback.
155+
// THEN users would lose the true progressing/deprecation messages.
147156
if cond == nil {
148-
// Create a new condition with a valid reason and add it
157+
// GIVEN no condition exists yet.
158+
// WHEN we add the fallback, users see the failure instead of a blank spot.
159+
// THEN richer helpers overwrite the fallback with the real progressing/bundle/package/channel message.
149160
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
150161
Type: condType,
151162
Status: metav1.ConditionFalse,
@@ -157,6 +168,17 @@ func ensureAllConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.C
157168
}
158169
}
159170

171+
// isDeprecationCondition reports whether the given type is one of the deprecation
172+
// conditions we manage separately.
173+
func isDeprecationCondition(condType string) bool {
174+
switch condType {
175+
case ocv1.TypeDeprecated, ocv1.TypePackageDeprecated, ocv1.TypeChannelDeprecated, ocv1.TypeBundleDeprecated:
176+
return true
177+
default:
178+
return false
179+
}
180+
}
181+
160182
// Compare resources - ignoring status & metadata.finalizers
161183
func checkForUnexpectedClusterExtensionFieldChange(a, b ocv1.ClusterExtension) bool {
162184
a.Status, b.Status = ocv1.ClusterExtensionStatus{}, ocv1.ClusterExtensionStatus{}
@@ -229,37 +251,41 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
229251
return ctrl.Result{}, err
230252
}
231253

254+
// Hold deprecation updates until the end. That way:
255+
// * if nothing installs, BundleDeprecated stays Unknown/Absent
256+
// * if a bundle installs, we report its real deprecation status
257+
// * install errors never leak into the deprecation conditions
258+
var resolvedDeprecation *declcfg.Deprecation
259+
defer func(resolvedDeprecationPtr **declcfg.Deprecation) {
260+
installedBundleName := ""
261+
if revisionStates != nil && revisionStates.Installed != nil {
262+
installedBundleName = revisionStates.Installed.Name
263+
}
264+
SetDeprecationStatus(ext, installedBundleName, *resolvedDeprecationPtr)
265+
}(&resolvedDeprecation)
266+
232267
var resolvedRevisionMetadata *RevisionMetadata
233268
if len(revisionStates.RollingOut) == 0 {
234269
l.Info("resolving bundle")
235270
var bm *ocv1.BundleMetadata
236271
if revisionStates.Installed != nil {
237272
bm = &revisionStates.Installed.BundleMetadata
238273
}
239-
resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolver.Resolve(ctx, ext, bm)
274+
var resolvedBundle *declcfg.Bundle
275+
var resolvedBundleVersion *bsemver.Version
276+
resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err = r.Resolver.Resolve(ctx, ext, bm)
277+
// Keep any deprecation data the resolver returned. The deferred update will use it
278+
// even if installation later fails or never begins.
240279
if err != nil {
241280
// Note: We don't distinguish between resolution-specific errors and generic errors
242281
setStatusProgressing(ext, err)
243282
setInstalledStatusFromRevisionStates(ext, revisionStates)
244-
ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error())
283+
// Ensure non-deprecation conditions capture the failure immediately. The deferred
284+
// SetDeprecationStatus call is responsible for updating the deprecation conditions
285+
// based on any catalog data returned by the resolver.
286+
ensureFailureConditionsWithReason(ext, ocv1.ReasonFailed, err.Error())
245287
return ctrl.Result{}, err
246288
}
247-
248-
// set deprecation status after _successful_ resolution
249-
// TODO:
250-
// 1. It seems like deprecation status should reflect the currently installed bundle, not the resolved
251-
// bundle. So perhaps we should set package and channel deprecations directly after resolution, but
252-
// defer setting the bundle deprecation until we successfully install the bundle.
253-
// 2. If resolution fails because it can't find a bundle, that doesn't mean we wouldn't be able to find
254-
// a deprecation for the ClusterExtension's spec.packageName. Perhaps we should check for a non-nil
255-
// resolvedDeprecation even if resolution returns an error. If present, we can still update some of
256-
// our deprecation status.
257-
// - Open question though: what if different catalogs have different opinions of what's deprecated.
258-
// If we can't resolve a bundle, how do we know which catalog to trust for deprecation information?
259-
// Perhaps if the package shows up in multiple catalogs and deprecations don't match, we can set
260-
// the deprecation status to unknown? Or perhaps we somehow combine the deprecation information from
261-
// all catalogs?
262-
SetDeprecationStatus(ext, resolvedBundle.Name, resolvedDeprecation)
263289
resolvedRevisionMetadata = &RevisionMetadata{
264290
Package: resolvedBundle.Package,
265291
Image: resolvedBundle.Image,
@@ -326,83 +352,141 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
326352
return ctrl.Result{}, nil
327353
}
328354

329-
// SetDeprecationStatus will set the appropriate deprecation statuses for a ClusterExtension
330-
// based on the provided bundle
331-
func SetDeprecationStatus(ext *ocv1.ClusterExtension, bundleName string, deprecation *declcfg.Deprecation) {
332-
deprecations := map[string][]declcfg.DeprecationEntry{}
333-
channelSet := sets.New[string]()
355+
// DeprecationInfo captures the deprecation data needed to update condition status.
356+
type DeprecationInfo struct {
357+
PackageEntries []declcfg.DeprecationEntry
358+
ChannelEntries []declcfg.DeprecationEntry
359+
BundleEntries []declcfg.DeprecationEntry
360+
BundleStatus metav1.ConditionStatus
361+
}
362+
363+
// SetDeprecationStatus updates the ClusterExtension deprecation conditions using the
364+
// catalog data from resolve plus the name of the bundle that actually landed. Examples:
365+
// - no bundle installed -> bundle status stays Unknown/Absent
366+
// - installed bundle marked deprecated -> bundle status True/Deprecated
367+
// - installed bundle not deprecated -> bundle status False/Deprecated
368+
//
369+
// This keeps the deprecation conditions focused on catalog information:
370+
// - PackageDeprecated: true if the catalog marks the package deprecated
371+
// - ChannelDeprecated: true if any requested channel is marked deprecated
372+
// - BundleDeprecated: reflects the installed bundle (Unknown/Absent when nothing installed)
373+
// - Deprecated (rollup): true if any of the above signals a deprecation
374+
//
375+
// Install or validation errors never appear here because they belong on the
376+
// Progressing/Installed conditions instead. Callers should invoke this after reconcile
377+
// finishes (for example via a defer) so catalog data replaces any transient error messages.
378+
func SetDeprecationStatus(ext *ocv1.ClusterExtension, installedBundleName string, deprecation *declcfg.Deprecation) {
379+
info := buildDeprecationInfo(ext, installedBundleName, deprecation)
380+
381+
packageMessages := collectDeprecationMessages(info.PackageEntries)
382+
channelMessages := collectDeprecationMessages(info.ChannelEntries)
383+
bundleMessages := collectDeprecationMessages(info.BundleEntries)
384+
385+
messages := slices.Concat(packageMessages, channelMessages, bundleMessages)
386+
387+
status := metav1.ConditionFalse
388+
if len(messages) > 0 {
389+
status = metav1.ConditionTrue
390+
}
391+
392+
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
393+
Type: ocv1.TypeDeprecated,
394+
Status: status,
395+
Reason: ocv1.ReasonDeprecated,
396+
Message: strings.Join(messages, "\n"),
397+
ObservedGeneration: ext.GetGeneration(),
398+
})
399+
400+
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
401+
Type: ocv1.TypePackageDeprecated,
402+
Status: conditionStatus(len(packageMessages) > 0),
403+
Reason: ocv1.ReasonDeprecated,
404+
Message: strings.Join(packageMessages, "\n"),
405+
ObservedGeneration: ext.GetGeneration(),
406+
})
407+
408+
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
409+
Type: ocv1.TypeChannelDeprecated,
410+
Status: conditionStatus(len(channelMessages) > 0),
411+
Reason: ocv1.ReasonDeprecated,
412+
Message: strings.Join(channelMessages, "\n"),
413+
ObservedGeneration: ext.GetGeneration(),
414+
})
415+
416+
bundleReason := ocv1.ReasonDeprecated
417+
bundleMessage := strings.Join(bundleMessages, "\n")
418+
if info.BundleStatus == metav1.ConditionUnknown {
419+
bundleReason = ocv1.ReasonAbsent
420+
bundleMessage = ""
421+
}
422+
423+
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
424+
Type: ocv1.TypeBundleDeprecated,
425+
Status: info.BundleStatus,
426+
Reason: bundleReason,
427+
Message: bundleMessage,
428+
ObservedGeneration: ext.GetGeneration(),
429+
})
430+
}
431+
432+
// buildDeprecationInfo filters the catalog deprecation data down to the package, channel,
433+
// and bundle entries that matter for this ClusterExtension. An empty bundle name means
434+
// nothing is installed yet, so we leave bundle status Unknown/Absent.
435+
func buildDeprecationInfo(ext *ocv1.ClusterExtension, installedBundleName string, deprecation *declcfg.Deprecation) DeprecationInfo {
436+
info := DeprecationInfo{BundleStatus: metav1.ConditionUnknown}
437+
var channelSet sets.Set[string]
334438
if ext.Spec.Source.Catalog != nil {
335-
for _, channel := range ext.Spec.Source.Catalog.Channels {
336-
channelSet.Insert(channel)
337-
}
439+
channelSet = sets.New(ext.Spec.Source.Catalog.Channels...)
440+
} else {
441+
channelSet = sets.New[string]()
338442
}
443+
339444
if deprecation != nil {
340445
for _, entry := range deprecation.Entries {
341446
switch entry.Reference.Schema {
342447
case declcfg.SchemaPackage:
343-
deprecations[ocv1.TypePackageDeprecated] = []declcfg.DeprecationEntry{entry}
448+
info.PackageEntries = append(info.PackageEntries, entry)
344449
case declcfg.SchemaChannel:
345450
if channelSet.Has(entry.Reference.Name) {
346-
deprecations[ocv1.TypeChannelDeprecated] = append(deprecations[ocv1.TypeChannelDeprecated], entry)
451+
info.ChannelEntries = append(info.ChannelEntries, entry)
347452
}
348453
case declcfg.SchemaBundle:
349-
if bundleName != entry.Reference.Name {
350-
continue
454+
if installedBundleName != "" && entry.Reference.Name == installedBundleName {
455+
info.BundleEntries = append(info.BundleEntries, entry)
351456
}
352-
deprecations[ocv1.TypeBundleDeprecated] = []declcfg.DeprecationEntry{entry}
353457
}
354458
}
355459
}
356460

357-
// first get ordered deprecation messages that we'll join in the Deprecated condition message
358-
var deprecationMessages []string
359-
for _, conditionType := range []string{
360-
ocv1.TypePackageDeprecated,
361-
ocv1.TypeChannelDeprecated,
362-
ocv1.TypeBundleDeprecated,
363-
} {
364-
if entries, ok := deprecations[conditionType]; ok {
365-
for _, entry := range entries {
366-
deprecationMessages = append(deprecationMessages, entry.Message)
367-
}
461+
// installedBundleName is empty when nothing is installed. In that case we want
462+
// to report the bundle deprecation condition as Unknown/Absent.
463+
if installedBundleName != "" {
464+
if len(info.BundleEntries) > 0 {
465+
info.BundleStatus = metav1.ConditionTrue
466+
} else {
467+
info.BundleStatus = metav1.ConditionFalse
368468
}
369469
}
370470

371-
// next, set the Deprecated condition
372-
status, reason, message := metav1.ConditionFalse, ocv1.ReasonDeprecated, ""
373-
if len(deprecationMessages) > 0 {
374-
status, reason, message = metav1.ConditionTrue, ocv1.ReasonDeprecated, strings.Join(deprecationMessages, ";")
375-
}
376-
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
377-
Type: ocv1.TypeDeprecated,
378-
Reason: reason,
379-
Status: status,
380-
Message: message,
381-
ObservedGeneration: ext.Generation,
382-
})
471+
return info
472+
}
383473

384-
// finally, set the individual deprecation conditions for package, channel, and bundle
385-
for _, conditionType := range []string{
386-
ocv1.TypePackageDeprecated,
387-
ocv1.TypeChannelDeprecated,
388-
ocv1.TypeBundleDeprecated,
389-
} {
390-
entries, ok := deprecations[conditionType]
391-
status, reason, message := metav1.ConditionFalse, ocv1.ReasonDeprecated, ""
392-
if ok {
393-
status, reason = metav1.ConditionTrue, ocv1.ReasonDeprecated
394-
for _, entry := range entries {
395-
message = fmt.Sprintf("%s\n%s", message, entry.Message)
396-
}
474+
// collectDeprecationMessages collects the non-empty deprecation messages from the provided entries.
475+
func collectDeprecationMessages(entries []declcfg.DeprecationEntry) []string {
476+
messages := make([]string, 0, len(entries))
477+
for _, entry := range entries {
478+
if entry.Message != "" {
479+
messages = append(messages, entry.Message)
397480
}
398-
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
399-
Type: conditionType,
400-
Reason: reason,
401-
Status: status,
402-
Message: message,
403-
ObservedGeneration: ext.Generation,
404-
})
405481
}
482+
return messages
483+
}
484+
485+
func conditionStatus(ok bool) metav1.ConditionStatus {
486+
if ok {
487+
return metav1.ConditionTrue
488+
}
489+
return metav1.ConditionFalse
406490
}
407491

408492
type ControllerBuilderOption func(builder *ctrl.Builder)

0 commit comments

Comments
 (0)