Skip to content

Commit 748e63f

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 748e63f

File tree

6 files changed

+393
-110
lines changed

6 files changed

+393
-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: 154 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,16 @@ 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)
147152
if cond == nil {
148-
// Create a new condition with a valid reason and add it
149153
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
150154
Type: condType,
151155
Status: metav1.ConditionFalse,
@@ -157,6 +161,17 @@ func ensureAllConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.C
157161
}
158162
}
159163

164+
// isDeprecationCondition reports whether the given type is one of the deprecation
165+
// conditions we manage separately.
166+
func isDeprecationCondition(condType string) bool {
167+
switch condType {
168+
case ocv1.TypeDeprecated, ocv1.TypePackageDeprecated, ocv1.TypeChannelDeprecated, ocv1.TypeBundleDeprecated:
169+
return true
170+
default:
171+
return false
172+
}
173+
}
174+
160175
// Compare resources - ignoring status & metadata.finalizers
161176
func checkForUnexpectedClusterExtensionFieldChange(a, b ocv1.ClusterExtension) bool {
162177
a.Status, b.Status = ocv1.ClusterExtensionStatus{}, ocv1.ClusterExtensionStatus{}
@@ -229,37 +244,41 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
229244
return ctrl.Result{}, err
230245
}
231246

247+
// Hold deprecation updates until the end. That way:
248+
// * if nothing installs, BundleDeprecated stays Unknown/Absent
249+
// * if a bundle installs, we report its real deprecation status
250+
// * install errors never leak into the deprecation conditions
251+
var resolvedDeprecation *declcfg.Deprecation
252+
defer func() {
253+
installedBundleName := ""
254+
if revisionStates != nil && revisionStates.Installed != nil {
255+
installedBundleName = revisionStates.Installed.Name
256+
}
257+
SetDeprecationStatus(ext, installedBundleName, resolvedDeprecation)
258+
}()
259+
232260
var resolvedRevisionMetadata *RevisionMetadata
233261
if len(revisionStates.RollingOut) == 0 {
234262
l.Info("resolving bundle")
235263
var bm *ocv1.BundleMetadata
236264
if revisionStates.Installed != nil {
237265
bm = &revisionStates.Installed.BundleMetadata
238266
}
239-
resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolver.Resolve(ctx, ext, bm)
267+
var resolvedBundle *declcfg.Bundle
268+
var resolvedBundleVersion *bsemver.Version
269+
resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err = r.Resolver.Resolve(ctx, ext, bm)
270+
// Keep any deprecation data the resolver returned. The deferred update will use it
271+
// even if installation later fails or never begins.
240272
if err != nil {
241273
// Note: We don't distinguish between resolution-specific errors and generic errors
242274
setStatusProgressing(ext, err)
243275
setInstalledStatusFromRevisionStates(ext, revisionStates)
244-
ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error())
276+
// Ensure non-deprecation conditions capture the failure immediately. The deferred
277+
// SetDeprecationStatus call is responsible for updating the deprecation conditions
278+
// based on any catalog data returned by the resolver.
279+
ensureFailureConditionsWithReason(ext, ocv1.ReasonFailed, err.Error())
245280
return ctrl.Result{}, err
246281
}
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)
263282
resolvedRevisionMetadata = &RevisionMetadata{
264283
Package: resolvedBundle.Package,
265284
Image: resolvedBundle.Image,
@@ -326,83 +345,140 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
326345
return ctrl.Result{}, nil
327346
}
328347

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

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-
}
454+
// installedBundleName is empty when nothing is installed. In that case we want
455+
// to report the bundle deprecation condition as Unknown/Absent.
456+
if installedBundleName != "" {
457+
if len(info.BundleEntries) > 0 {
458+
info.BundleStatus = metav1.ConditionTrue
459+
} else {
460+
info.BundleStatus = metav1.ConditionFalse
368461
}
369462
}
370463

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-
})
464+
return info
465+
}
383466

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-
}
467+
func collectDeprecationMessages(entries []declcfg.DeprecationEntry) []string {
468+
messages := make([]string, 0, len(entries))
469+
for _, entry := range entries {
470+
if entry.Message != "" {
471+
messages = append(messages, entry.Message)
397472
}
398-
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
399-
Type: conditionType,
400-
Reason: reason,
401-
Status: status,
402-
Message: message,
403-
ObservedGeneration: ext.Generation,
404-
})
405473
}
474+
return messages
475+
}
476+
477+
func conditionStatus(ok bool) metav1.ConditionStatus {
478+
if ok {
479+
return metav1.ConditionTrue
480+
}
481+
return metav1.ConditionFalse
406482
}
407483

408484
type ControllerBuilderOption func(builder *ctrl.Builder)

0 commit comments

Comments
 (0)