diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go
index 6de62b0e1..bcda26b5d 100644
--- a/api/v1/clusterextension_types.go
+++ b/api/v1/clusterextension_types.go
@@ -483,12 +483,15 @@ type ClusterExtensionStatus struct {
// When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
// When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
//
- // When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
+ // When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
// These are indications from a package owner to guide users away from a particular package, channel, or bundle.
- // BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
- // ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
- // PackageDeprecated is set if the requested package is marked deprecated in the catalog.
- // Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
+ // PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
+ // about the package, the condition stays Unknown instead of claiming False.
+ // ChannelDeprecated follows the same rules for each requested channel.
+ // BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
+ // data is available, it remains Unknown.
+ // Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
+ // when we have no catalog information to report.
//
// +listType=map
// +listMapKey=type
diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md
index 317b46a00..896b31b6f 100644
--- a/docs/api-reference/olmv1-api-reference.md
+++ b/docs/api-reference/olmv1-api-reference.md
@@ -359,7 +359,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
-| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | The set of condition types which apply to all spec.source variations are Installed and Progressing.
The Installed condition represents whether or not the bundle has been installed for this ClusterExtension.
When Installed is True and the Reason is Succeeded, the bundle has been successfully installed.
When Installed is False and the Reason is Failed, the bundle has failed to install.
The Progressing condition represents whether or not the ClusterExtension is advancing towards a new state.
When Progressing is True and the Reason is Succeeded, the ClusterExtension is making progress towards a new state.
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
PackageDeprecated is set if the requested package is marked deprecated in the catalog.
Deprecated is a rollup condition that is present when any of the deprecated conditions are present. | | |
+| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | The set of condition types which apply to all spec.source variations are Installed and Progressing.
The Installed condition represents whether or not the bundle has been installed for this ClusterExtension.
When Installed is True and the Reason is Succeeded, the bundle has been successfully installed.
When Installed is False and the Reason is Failed, the bundle has failed to install.
The Progressing condition represents whether or not the ClusterExtension is advancing towards a new state.
When Progressing is True and the Reason is Succeeded, the ClusterExtension is making progress towards a new state.
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
about the package, the condition stays Unknown instead of claiming False.
ChannelDeprecated follows the same rules for each requested channel.
BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
data is available, it remains Unknown.
Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
when we have no catalog information to report. | | |
| `install` _[ClusterExtensionInstallStatus](#clusterextensioninstallstatus)_ | install is a representation of the current installation status for this ClusterExtension. | | |
diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
index 1038b7fdf..16fedc814 100644
--- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
+++ b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml
@@ -518,12 +518,15 @@ spec:
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
- When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
+ When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
- BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
- ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
- PackageDeprecated is set if the requested package is marked deprecated in the catalog.
- Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
+ PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
+ about the package, the condition stays Unknown instead of claiming False.
+ ChannelDeprecated follows the same rules for each requested channel.
+ BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
+ data is available, it remains Unknown.
+ Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
+ when we have no catalog information to report.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
diff --git a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
index a0983e41f..1b213c5be 100644
--- a/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
+++ b/helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml
@@ -479,12 +479,15 @@ spec:
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
- When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
+ When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
- BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
- ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
- PackageDeprecated is set if the requested package is marked deprecated in the catalog.
- Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
+ PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
+ about the package, the condition stays Unknown instead of claiming False.
+ ChannelDeprecated follows the same rules for each requested channel.
+ BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
+ data is available, it remains Unknown.
+ Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
+ when we have no catalog information to report.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
diff --git a/internal/operator-controller/controllers/clusterextension_admission_test.go b/internal/operator-controller/controllers/clusterextension_admission_test.go
index 38c6c60d4..8b572dbe8 100644
--- a/internal/operator-controller/controllers/clusterextension_admission_test.go
+++ b/internal/operator-controller/controllers/clusterextension_admission_test.go
@@ -13,7 +13,7 @@ import (
)
func TestClusterExtensionSourceConfig(t *testing.T) {
- sourceTypeEmptyError := "Invalid value: null"
+ sourceTypeEmptyErrors := []string{"Invalid value: \"null\"", "Invalid value: null"}
sourceTypeMismatchError := "spec.source.sourceType: Unsupported value"
sourceConfigInvalidError := "spec.source: Invalid value"
// unionField represents the required Catalog or (future) Bundle field required by SourceConfig
@@ -21,12 +21,12 @@ func TestClusterExtensionSourceConfig(t *testing.T) {
name string
sourceType string
unionField string
- errMsg string
+ errMsgs []string
}{
- {"sourceType is null", "", "Catalog", sourceTypeEmptyError},
- {"sourceType is invalid", "Invalid", "Catalog", sourceTypeMismatchError},
- {"catalog field does not exist", "Catalog", "", sourceConfigInvalidError},
- {"sourceConfig has required fields", "Catalog", "Catalog", ""},
+ {"sourceType is null", "", "Catalog", sourceTypeEmptyErrors},
+ {"sourceType is invalid", "Invalid", "Catalog", []string{sourceTypeMismatchError}},
+ {"catalog field does not exist", "Catalog", "", []string{sourceConfigInvalidError}},
+ {"sourceConfig has required fields", "Catalog", "Catalog", nil},
}
t.Parallel()
@@ -62,12 +62,20 @@ func TestClusterExtensionSourceConfig(t *testing.T) {
}))
}
- if tc.errMsg == "" {
+ if len(tc.errMsgs) == 0 {
require.NoError(t, err, "unexpected error for sourceType %q: %w", tc.sourceType, err)
- } else {
- require.Error(t, err)
- require.Contains(t, err.Error(), tc.errMsg)
+ return
+ }
+
+ require.Error(t, err)
+ matched := false
+ for _, msg := range tc.errMsgs {
+ if strings.Contains(err.Error(), msg) {
+ matched = true
+ break
+ }
}
+ require.True(t, matched, "expected one of %v in error %q", tc.errMsgs, err)
})
}
}
diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go
index 7bcedde65..734768856 100644
--- a/internal/operator-controller/controllers/clusterextension_controller.go
+++ b/internal/operator-controller/controllers/clusterextension_controller.go
@@ -25,6 +25,7 @@ import (
"slices"
"strings"
+ bsemver "github.com/blang/semver/v4"
"github.com/go-logr/logr"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver"
@@ -139,13 +140,20 @@ func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Req
return res, reconcileErr
}
-// ensureAllConditionsWithReason checks that all defined condition types exist in the given ClusterExtension,
-// and assigns a specified reason and custom message to any missing condition.
-func ensureAllConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.ConditionReason, message string) {
+// ensureFailureConditionsWithReason keeps every non-deprecation condition present.
+// If one is missing, we add it with the given reason and message so users see why
+// reconcile failed. Deprecation conditions are handled later by SetDeprecationStatus.
+func ensureFailureConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.ConditionReason, message string) {
for _, condType := range conditionsets.ConditionTypes {
+ if isDeprecationCondition(condType) {
+ continue
+ }
cond := apimeta.FindStatusCondition(ext.Status.Conditions, condType)
+ // Guard so we only fill empty slots. Without it, we would overwrite the detailed status that
+ // helpers (setStatusProgressing, setInstalledStatusCondition*, SetDeprecationStatus) already set.
if cond == nil {
- // Create a new condition with a valid reason and add it
+ // No condition exists yet, so add a fallback with the failure reason. Specific helpers replace it
+ // with the real progressing/bundle/package/channel message during reconciliation.
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
Type: condType,
Status: metav1.ConditionFalse,
@@ -157,6 +165,17 @@ func ensureAllConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.C
}
}
+// isDeprecationCondition reports whether the given type is one of the deprecation
+// conditions we manage separately.
+func isDeprecationCondition(condType string) bool {
+ switch condType {
+ case ocv1.TypeDeprecated, ocv1.TypePackageDeprecated, ocv1.TypeChannelDeprecated, ocv1.TypeBundleDeprecated:
+ return true
+ default:
+ return false
+ }
+}
+
// Compare resources - ignoring status & metadata.finalizers
func checkForUnexpectedClusterExtensionFieldChange(a, b ocv1.ClusterExtension) bool {
a.Status, b.Status = ocv1.ClusterExtensionStatus{}, ocv1.ClusterExtensionStatus{}
@@ -229,6 +248,28 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
return ctrl.Result{}, err
}
+ // Hold deprecation updates until the end via defer. This ensures:
+ // * BundleDeprecated stays Unknown/Absent when nothing installs
+ // * We report the real deprecation status once a bundle installs
+ // * Install errors never leak into deprecation conditions
+ //
+ // The defer closure reads these variables when reconciliation ends:
+ // * resolvedDeprecation - catalog warnings from the resolver
+ // * hadCatalogDeprecationData - true if the catalog responded (even if resolution failed)
+ //
+ // Example: If resolution fails but returns deprecation data, hadCatalogDeprecationData=true
+ // and the deprecation warnings reach users. If the catalog is removed, hadCatalogDeprecationData=false
+ // and all deprecation conditions go Unknown.
+ var resolvedDeprecation *declcfg.Deprecation
+ var hadCatalogDeprecationData bool
+ defer func() {
+ installedBundleName := ""
+ if revisionStates != nil && revisionStates.Installed != nil {
+ installedBundleName = revisionStates.Installed.Name
+ }
+ SetDeprecationStatus(ext, installedBundleName, resolvedDeprecation, hadCatalogDeprecationData)
+ }()
+
var resolvedRevisionMetadata *RevisionMetadata
if len(revisionStates.RollingOut) == 0 {
l.Info("resolving bundle")
@@ -236,30 +277,24 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
if revisionStates.Installed != nil {
bm = &revisionStates.Installed.BundleMetadata
}
- resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolver.Resolve(ctx, ext, bm)
+ var resolvedBundle *declcfg.Bundle
+ var resolvedBundleVersion *bsemver.Version
+ resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err = r.Resolver.Resolve(ctx, ext, bm)
+ if err == nil || resolvedDeprecation != nil {
+ hadCatalogDeprecationData = true
+ }
+ // Keep any deprecation data the resolver returned. The deferred update will use it
+ // even if installation later fails or never begins.
if err != nil {
// Note: We don't distinguish between resolution-specific errors and generic errors
setStatusProgressing(ext, err)
setInstalledStatusFromRevisionStates(ext, revisionStates)
- ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error())
+ // Ensure non-deprecation conditions capture the failure immediately. The deferred
+ // SetDeprecationStatus call is responsible for updating the deprecation conditions
+ // based on any catalog data returned by the resolver.
+ ensureFailureConditionsWithReason(ext, ocv1.ReasonFailed, err.Error())
return ctrl.Result{}, err
}
-
- // set deprecation status after _successful_ resolution
- // TODO:
- // 1. It seems like deprecation status should reflect the currently installed bundle, not the resolved
- // bundle. So perhaps we should set package and channel deprecations directly after resolution, but
- // defer setting the bundle deprecation until we successfully install the bundle.
- // 2. If resolution fails because it can't find a bundle, that doesn't mean we wouldn't be able to find
- // a deprecation for the ClusterExtension's spec.packageName. Perhaps we should check for a non-nil
- // resolvedDeprecation even if resolution returns an error. If present, we can still update some of
- // our deprecation status.
- // - Open question though: what if different catalogs have different opinions of what's deprecated.
- // If we can't resolve a bundle, how do we know which catalog to trust for deprecation information?
- // Perhaps if the package shows up in multiple catalogs and deprecations don't match, we can set
- // the deprecation status to unknown? Or perhaps we somehow combine the deprecation information from
- // all catalogs?
- SetDeprecationStatus(ext, resolvedBundle.Name, resolvedDeprecation)
resolvedRevisionMetadata = &RevisionMetadata{
Package: resolvedBundle.Package,
Image: resolvedBundle.Image,
@@ -326,83 +361,135 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
return ctrl.Result{}, nil
}
-// SetDeprecationStatus will set the appropriate deprecation statuses for a ClusterExtension
-// based on the provided bundle
-func SetDeprecationStatus(ext *ocv1.ClusterExtension, bundleName string, deprecation *declcfg.Deprecation) {
- deprecations := map[string][]declcfg.DeprecationEntry{}
+// DeprecationInfo captures the deprecation data needed to update condition status.
+type DeprecationInfo struct {
+ PackageEntries []declcfg.DeprecationEntry
+ ChannelEntries []declcfg.DeprecationEntry
+ BundleEntries []declcfg.DeprecationEntry
+ BundleStatus metav1.ConditionStatus
+}
+
+// SetDeprecationStatus updates the ClusterExtension deprecation conditions using the
+// catalog data from resolve plus the name of the bundle that actually landed. Examples:
+// - no bundle installed -> bundle status stays Unknown/Absent
+// - installed bundle marked deprecated -> bundle status True/Deprecated
+// - installed bundle not deprecated -> bundle status False/Deprecated
+//
+// This keeps the deprecation conditions focused on catalog information:
+// - PackageDeprecated: true if the catalog marks the package deprecated
+// - ChannelDeprecated: true if any requested channel is marked deprecated
+// - BundleDeprecated: reflects the installed bundle (Unknown/Absent when nothing installed)
+// - Deprecated (rollup): true if any of the above signals a deprecation
+//
+// Install or validation errors never appear here because they belong on the
+// Progressing/Installed conditions instead. Callers should invoke this after reconcile
+// finishes (for example via a defer) so catalog data replaces any transient error messages.
+func SetDeprecationStatus(ext *ocv1.ClusterExtension, installedBundleName string, deprecation *declcfg.Deprecation, hasCatalogData bool) {
+ info := buildDeprecationInfo(ext, installedBundleName, deprecation)
+ packageMessages := collectDeprecationMessages(info.PackageEntries)
+ channelMessages := collectDeprecationMessages(info.ChannelEntries)
+ bundleMessages := collectDeprecationMessages(info.BundleEntries)
+
+ if !hasCatalogData {
+ // When catalog is unavailable (e.g. removed), all conditions go Unknown.
+ // BundleDeprecated uses Absent only when no bundle installed.
+ bundleReason := ocv1.ReasonAbsent
+ if installedBundleName != "" {
+ bundleReason = ocv1.ReasonDeprecated
+ }
+ setDeprecationCondition(ext, ocv1.TypeDeprecated, metav1.ConditionUnknown, ocv1.ReasonDeprecated, "")
+ setDeprecationCondition(ext, ocv1.TypePackageDeprecated, metav1.ConditionUnknown, ocv1.ReasonDeprecated, "")
+ setDeprecationCondition(ext, ocv1.TypeChannelDeprecated, metav1.ConditionUnknown, ocv1.ReasonDeprecated, "")
+ setDeprecationCondition(ext, ocv1.TypeBundleDeprecated, metav1.ConditionUnknown, bundleReason, "")
+ return
+ }
+
+ messages := slices.Concat(packageMessages, channelMessages, bundleMessages)
+ deprecatedStatus := metav1.ConditionFalse
+ if len(messages) > 0 {
+ deprecatedStatus = metav1.ConditionTrue
+ }
+
+ setDeprecationCondition(ext, ocv1.TypeDeprecated, deprecatedStatus, ocv1.ReasonDeprecated, strings.Join(messages, "\n"))
+ setDeprecationCondition(ext, ocv1.TypePackageDeprecated, conditionStatus(len(packageMessages) > 0), ocv1.ReasonDeprecated, strings.Join(packageMessages, "\n"))
+ setDeprecationCondition(ext, ocv1.TypeChannelDeprecated, conditionStatus(len(channelMessages) > 0), ocv1.ReasonDeprecated, strings.Join(channelMessages, "\n"))
+
+ bundleReason := ocv1.ReasonDeprecated
+ bundleMessage := strings.Join(bundleMessages, "\n")
+ if info.BundleStatus == metav1.ConditionUnknown {
+ bundleReason = ocv1.ReasonAbsent
+ bundleMessage = ""
+ }
+ setDeprecationCondition(ext, ocv1.TypeBundleDeprecated, info.BundleStatus, bundleReason, bundleMessage)
+}
+
+// setDeprecationCondition sets a single deprecation condition with less boilerplate.
+func setDeprecationCondition(ext *ocv1.ClusterExtension, condType string, status metav1.ConditionStatus, reason string, message string) {
+ SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
+ Type: condType,
+ Status: status,
+ Reason: reason,
+ Message: message,
+ ObservedGeneration: ext.GetGeneration(),
+ })
+}
+
+// buildDeprecationInfo filters the catalog deprecation data down to the package, channel,
+// and bundle entries that matter for this ClusterExtension. An empty bundle name means
+// nothing is installed yet, so we leave bundle status Unknown/Absent.
+func buildDeprecationInfo(ext *ocv1.ClusterExtension, installedBundleName string, deprecation *declcfg.Deprecation) DeprecationInfo {
+ info := DeprecationInfo{BundleStatus: metav1.ConditionUnknown}
channelSet := sets.New[string]()
if ext.Spec.Source.Catalog != nil {
- for _, channel := range ext.Spec.Source.Catalog.Channels {
- channelSet.Insert(channel)
- }
+ channelSet.Insert(ext.Spec.Source.Catalog.Channels...)
}
+
if deprecation != nil {
for _, entry := range deprecation.Entries {
switch entry.Reference.Schema {
case declcfg.SchemaPackage:
- deprecations[ocv1.TypePackageDeprecated] = []declcfg.DeprecationEntry{entry}
+ info.PackageEntries = append(info.PackageEntries, entry)
case declcfg.SchemaChannel:
if channelSet.Has(entry.Reference.Name) {
- deprecations[ocv1.TypeChannelDeprecated] = append(deprecations[ocv1.TypeChannelDeprecated], entry)
+ info.ChannelEntries = append(info.ChannelEntries, entry)
}
case declcfg.SchemaBundle:
- if bundleName != entry.Reference.Name {
- continue
+ if installedBundleName != "" && entry.Reference.Name == installedBundleName {
+ info.BundleEntries = append(info.BundleEntries, entry)
}
- deprecations[ocv1.TypeBundleDeprecated] = []declcfg.DeprecationEntry{entry}
}
}
}
- // first get ordered deprecation messages that we'll join in the Deprecated condition message
- var deprecationMessages []string
- for _, conditionType := range []string{
- ocv1.TypePackageDeprecated,
- ocv1.TypeChannelDeprecated,
- ocv1.TypeBundleDeprecated,
- } {
- if entries, ok := deprecations[conditionType]; ok {
- for _, entry := range entries {
- deprecationMessages = append(deprecationMessages, entry.Message)
- }
+ // installedBundleName is empty when nothing is installed. In that case we want
+ // to report the bundle deprecation condition as Unknown/Absent.
+ if installedBundleName != "" {
+ if len(info.BundleEntries) > 0 {
+ info.BundleStatus = metav1.ConditionTrue
+ } else {
+ info.BundleStatus = metav1.ConditionFalse
}
}
- // next, set the Deprecated condition
- status, reason, message := metav1.ConditionFalse, ocv1.ReasonDeprecated, ""
- if len(deprecationMessages) > 0 {
- status, reason, message = metav1.ConditionTrue, ocv1.ReasonDeprecated, strings.Join(deprecationMessages, ";")
- }
- SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
- Type: ocv1.TypeDeprecated,
- Reason: reason,
- Status: status,
- Message: message,
- ObservedGeneration: ext.Generation,
- })
+ return info
+}
- // finally, set the individual deprecation conditions for package, channel, and bundle
- for _, conditionType := range []string{
- ocv1.TypePackageDeprecated,
- ocv1.TypeChannelDeprecated,
- ocv1.TypeBundleDeprecated,
- } {
- entries, ok := deprecations[conditionType]
- status, reason, message := metav1.ConditionFalse, ocv1.ReasonDeprecated, ""
- if ok {
- status, reason = metav1.ConditionTrue, ocv1.ReasonDeprecated
- for _, entry := range entries {
- message = fmt.Sprintf("%s\n%s", message, entry.Message)
- }
+// collectDeprecationMessages collects the non-empty deprecation messages from the provided entries.
+func collectDeprecationMessages(entries []declcfg.DeprecationEntry) []string {
+ messages := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ if entry.Message != "" {
+ messages = append(messages, entry.Message)
}
- SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
- Type: conditionType,
- Reason: reason,
- Status: status,
- Message: message,
- ObservedGeneration: ext.Generation,
- })
}
+ return messages
+}
+
+func conditionStatus(ok bool) metav1.ConditionStatus {
+ if ok {
+ return metav1.ConditionTrue
+ }
+ return metav1.ConditionFalse
}
type ControllerBuilderOption func(builder *ctrl.Builder)
diff --git a/internal/operator-controller/controllers/clusterextension_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go
index 437f62dce..df46498a6 100644
--- a/internal/operator-controller/controllers/clusterextension_controller_test.go
+++ b/internal/operator-controller/controllers/clusterextension_controller_test.go
@@ -31,6 +31,7 @@ import (
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
"github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets"
"github.com/operator-framework/operator-controller/internal/operator-controller/controllers"
+ "github.com/operator-framework/operator-controller/internal/operator-controller/features"
"github.com/operator-framework/operator-controller/internal/operator-controller/finalizers"
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
"github.com/operator-framework/operator-controller/internal/operator-controller/resolve"
@@ -172,6 +173,142 @@ func TestClusterExtensionResolutionFails(t *testing.T) {
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
}
+// TestClusterExtensionResolutionFailsWithDeprecationData verifies that deprecation warnings are shown even when resolution fails.
+//
+// Scenario:
+// - Resolution fails (package not found or version not available)
+// - Resolver returns deprecation data along with the error
+// - Catalog has marked the package as deprecated
+// - PackageDeprecated and Deprecated conditions show True with the deprecation message
+// - BundleDeprecated stays Unknown/Absent because no bundle is installed yet
+//
+// This ensures deprecation warnings reach users even when installation cannot proceed.
+func TestClusterExtensionResolutionFailsWithDeprecationData(t *testing.T) {
+ ctx := context.Background()
+ cl, reconciler := newClientAndReconciler(t)
+ deprecationMessage := "package marked deprecated in catalog"
+ pkgName := fmt.Sprintf("deprecated-%s", rand.String(6))
+ reconciler.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) {
+ return nil, nil, &declcfg.Deprecation{
+ Entries: []declcfg.DeprecationEntry{{
+ Reference: declcfg.PackageScopedReference{Schema: declcfg.SchemaPackage},
+ Message: deprecationMessage,
+ }},
+ }, fmt.Errorf("no package %q found", pkgName)
+ })
+
+ extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
+ clusterExtension := &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
+ Spec: ocv1.ClusterExtensionSpec{
+ Source: ocv1.SourceConfig{
+ SourceType: "Catalog",
+ Catalog: &ocv1.CatalogFilter{PackageName: pkgName},
+ },
+ Namespace: "default",
+ ServiceAccount: ocv1.ServiceAccountReference{Name: "default"},
+ },
+ }
+ require.NoError(t, cl.Create(ctx, clusterExtension))
+
+ res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
+ require.Equal(t, ctrl.Result{}, res)
+ require.EqualError(t, err, fmt.Sprintf("no package %q found", pkgName))
+
+ require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
+
+ pkgCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypePackageDeprecated)
+ require.NotNil(t, pkgCond)
+ require.Equal(t, metav1.ConditionTrue, pkgCond.Status)
+ require.Equal(t, deprecationMessage, pkgCond.Message)
+
+ deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated)
+ require.NotNil(t, deprecatedCond)
+ require.Equal(t, metav1.ConditionTrue, deprecatedCond.Status)
+
+ bundleCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeBundleDeprecated)
+ require.NotNil(t, bundleCond)
+ require.Equal(t, metav1.ConditionUnknown, bundleCond.Status, "no bundle installed yet, so keep it Unknown/Absent")
+ require.Equal(t, ocv1.ReasonAbsent, bundleCond.Reason)
+
+ verifyInvariants(ctx, t, reconciler.Client, clusterExtension)
+ require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
+}
+
+// TestClusterExtensionResolutionFailsWithoutCatalogDeprecationData verifies deprecation status handling when catalog data is unavailable.
+//
+// Scenario:
+// - A bundle is already installed (v1.0.0)
+// - Catalog is removed or resolution fails (no catalog data available)
+// - Resolution error is returned with no deprecation data
+// - All deprecation conditions must be set to Unknown (not False)
+// - BundleDeprecated uses reason Deprecated (not Absent) because a bundle exists
+//
+// This ensures users see "we don't know the deprecation status" rather than "definitely not deprecated"
+// when the catalog source of truth is unavailable.
+func TestClusterExtensionResolutionFailsWithoutCatalogDeprecationData(t *testing.T) {
+ ctx := context.Background()
+ cl, reconciler := newClientAndReconciler(t)
+ pkgName := fmt.Sprintf("missing-%s", rand.String(6))
+ installedBundleName := fmt.Sprintf("%s.v1.0.0", pkgName)
+ reconciler.Resolver = resolve.Func(func(context.Context, *ocv1.ClusterExtension, *ocv1.BundleMetadata) (*declcfg.Bundle, *bsemver.Version, *declcfg.Deprecation, error) {
+ return nil, nil, nil, fmt.Errorf("no bundles found for package %q", pkgName)
+ })
+ reconciler.RevisionStatesGetter = &MockRevisionStatesGetter{
+ RevisionStates: &controllers.RevisionStates{
+ Installed: &controllers.RevisionMetadata{
+ Package: pkgName,
+ BundleMetadata: ocv1.BundleMetadata{
+ Name: installedBundleName,
+ Version: "1.0.0",
+ },
+ Image: "example.com/installed@sha256:deadbeef",
+ },
+ },
+ }
+
+ extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
+ clusterExtension := &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
+ Spec: ocv1.ClusterExtensionSpec{
+ Source: ocv1.SourceConfig{
+ SourceType: "Catalog",
+ Catalog: &ocv1.CatalogFilter{PackageName: pkgName},
+ },
+ Namespace: "default",
+ ServiceAccount: ocv1.ServiceAccountReference{Name: "default"},
+ },
+ }
+ require.NoError(t, cl.Create(ctx, clusterExtension))
+
+ res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
+ require.Equal(t, ctrl.Result{}, res)
+ require.EqualError(t, err, fmt.Sprintf("no bundles found for package %q", pkgName))
+
+ require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
+
+ packageCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypePackageDeprecated)
+ require.NotNil(t, packageCond)
+ require.Equal(t, metav1.ConditionUnknown, packageCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, packageCond.Reason)
+ require.Empty(t, packageCond.Message)
+
+ deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated)
+ require.NotNil(t, deprecatedCond)
+ require.Equal(t, metav1.ConditionUnknown, deprecatedCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, deprecatedCond.Reason)
+ require.Empty(t, deprecatedCond.Message)
+
+ bundleCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeBundleDeprecated)
+ require.NotNil(t, bundleCond)
+ require.Equal(t, metav1.ConditionUnknown, bundleCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, bundleCond.Reason)
+ require.Empty(t, bundleCond.Message)
+
+ verifyInvariants(ctx, t, reconciler.Client, clusterExtension)
+ require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
+}
+
func TestClusterExtensionResolutionSuccessfulUnpackFails(t *testing.T) {
type testCase struct {
name string
@@ -264,6 +401,24 @@ func TestClusterExtensionResolutionSuccessfulUnpackFails(t *testing.T) {
require.Equal(t, expectReason, progressingCond.Reason)
require.Contains(t, progressingCond.Message, fmt.Sprintf("for resolved bundle %q with version %q", expectedBundleMetadata.Name, expectedBundleMetadata.Version))
+ t.Log("By checking deprecation conditions remain neutral and bundle is Unknown when not installed")
+ deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated)
+ require.NotNil(t, deprecatedCond)
+ require.Equal(t, metav1.ConditionFalse, deprecatedCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, deprecatedCond.Reason)
+ pkgCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypePackageDeprecated)
+ require.NotNil(t, pkgCond)
+ require.Equal(t, metav1.ConditionFalse, pkgCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, pkgCond.Reason)
+ chanCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeChannelDeprecated)
+ require.NotNil(t, chanCond)
+ require.Equal(t, metav1.ConditionFalse, chanCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, chanCond.Reason)
+ bundleCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeBundleDeprecated)
+ require.NotNil(t, bundleCond)
+ require.Equal(t, metav1.ConditionUnknown, bundleCond.Status)
+ require.Equal(t, ocv1.ReasonAbsent, bundleCond.Reason)
+
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
})
}
@@ -343,6 +498,121 @@ func TestClusterExtensionResolutionAndUnpackSuccessfulApplierFails(t *testing.T)
require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
require.Contains(t, progressingCond.Message, fmt.Sprintf("for resolved bundle %q with version %q", expectedBundleMetadata.Name, expectedBundleMetadata.Version))
+ t.Log("By checking deprecation conditions remain neutral and bundle is Unknown when not installed")
+ deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated)
+ require.NotNil(t, deprecatedCond)
+ require.Equal(t, metav1.ConditionFalse, deprecatedCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, deprecatedCond.Reason)
+ pkgCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypePackageDeprecated)
+ require.NotNil(t, pkgCond)
+ require.Equal(t, metav1.ConditionFalse, pkgCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, pkgCond.Reason)
+ chanCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeChannelDeprecated)
+ require.NotNil(t, chanCond)
+ require.Equal(t, metav1.ConditionFalse, chanCond.Status)
+ require.Equal(t, ocv1.ReasonDeprecated, chanCond.Reason)
+ bundleCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeBundleDeprecated)
+ require.NotNil(t, bundleCond)
+ require.Equal(t, metav1.ConditionUnknown, bundleCond.Status)
+ require.Equal(t, ocv1.ReasonAbsent, bundleCond.Reason)
+
+ require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
+}
+
+// TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors verifies deprecation status when apply fails.
+//
+// Scenario:
+// - Resolution succeeds and returns a valid bundle (prometheus.v1.0.0)
+// - Boxcutter applier fails during rollout (simulates apply failure)
+// - A rolling revision exists but nothing is installed yet
+// - Progressing condition shows the apply error (Retrying)
+// - Deprecation conditions reflect catalog data (all False since nothing deprecated)
+// - BundleDeprecated stays Unknown/Absent because apply failed before install
+//
+// This ensures apply errors appear in Progressing condition, not in deprecation conditions.
+func TestClusterExtensionBoxcutterApplierFailsDoesNotLeakDeprecationErrors(t *testing.T) {
+ require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.BoxcutterRuntime)))
+ t.Cleanup(func() {
+ require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.BoxcutterRuntime)))
+ })
+
+ cl, reconciler := newClientAndReconciler(t)
+ // Boxcutter keeps a rolling revision when apply fails. We mirror that state so the test uses
+ // the same inputs the runtime would see.
+ reconciler.RevisionStatesGetter = &MockRevisionStatesGetter{
+ RevisionStates: &controllers.RevisionStates{
+ RollingOut: []*controllers.RevisionMetadata{{}},
+ },
+ }
+ reconciler.ImagePuller = &imageutil.MockPuller{ImageFS: fstest.MapFS{}}
+
+ ctx := context.Background()
+ extKey := types.NamespacedName{Name: fmt.Sprintf("cluster-extension-test-%s", rand.String(8))}
+
+ t.Log("When the Boxcutter Feature Flag is enabled and apply fails")
+ clusterExtension := &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{Name: extKey.Name},
+ Spec: ocv1.ClusterExtensionSpec{
+ Source: ocv1.SourceConfig{
+ SourceType: "Catalog",
+ Catalog: &ocv1.CatalogFilter{
+ PackageName: "prometheus",
+ Version: "1.0.0",
+ Channels: []string{"beta"},
+ },
+ },
+ Namespace: "default",
+ ServiceAccount: ocv1.ServiceAccountReference{
+ Name: "default",
+ },
+ },
+ }
+ require.NoError(t, cl.Create(ctx, clusterExtension))
+
+ reconciler.Applier = &MockApplier{err: errors.New("boxcutter apply failure")}
+
+ res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: extKey})
+ require.Equal(t, ctrl.Result{}, res)
+ require.Error(t, err)
+
+ require.NoError(t, cl.Get(ctx, extKey, clusterExtension))
+
+ installedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
+ require.NotNil(t, installedCond)
+ require.Equal(t, metav1.ConditionFalse, installedCond.Status)
+ require.Equal(t, ocv1.ReasonAbsent, installedCond.Reason)
+ require.Contains(t, installedCond.Message, "No bundle installed")
+
+ progressingCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing)
+ require.NotNil(t, progressingCond)
+ require.Equal(t, metav1.ConditionTrue, progressingCond.Status)
+ require.Equal(t, ocv1.ReasonRetrying, progressingCond.Reason)
+ require.Contains(t, progressingCond.Message, "boxcutter apply failure")
+
+ deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated)
+ require.NotNil(t, deprecatedCond)
+ require.Equal(t, metav1.ConditionUnknown, deprecatedCond.Status, "no catalog data during rollout, so Unknown")
+ require.Equal(t, ocv1.ReasonDeprecated, deprecatedCond.Reason)
+ require.Empty(t, deprecatedCond.Message)
+
+ packageCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypePackageDeprecated)
+ require.NotNil(t, packageCond)
+ require.Equal(t, metav1.ConditionUnknown, packageCond.Status, "no catalog data during rollout, so Unknown")
+ require.Equal(t, ocv1.ReasonDeprecated, packageCond.Reason)
+ require.Empty(t, packageCond.Message)
+
+ channelCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeChannelDeprecated)
+ require.NotNil(t, channelCond)
+ require.Equal(t, metav1.ConditionUnknown, channelCond.Status, "no catalog data during rollout, so Unknown")
+ require.Equal(t, ocv1.ReasonDeprecated, channelCond.Reason)
+ require.Empty(t, channelCond.Message)
+
+ bundleCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeBundleDeprecated)
+ require.NotNil(t, bundleCond)
+ require.Equal(t, metav1.ConditionUnknown, bundleCond.Status, "apply failed before install, so bundle status stays Unknown/Absent")
+ require.Equal(t, ocv1.ReasonAbsent, bundleCond.Reason)
+ require.Empty(t, bundleCond.Message)
+
require.NoError(t, cl.DeleteAllOf(ctx, &ocv1.ClusterExtension{}))
}
@@ -844,15 +1114,161 @@ func verifyConditionsInvariants(t *testing.T, ext *ocv1.ClusterExtension) {
}
func TestSetDeprecationStatus(t *testing.T) {
+ // The catalogDataProvided/hasCatalogData pair lets each test express whether the catalog
+ // answered during reconciliation and, if it did, whether it marked anything as deprecated.
+ // This helps us cover three distinct user-facing states: "no catalog response" (everything
+ // stays Unknown), "catalog answered with no deprecations" (conditions go False), and
+ // "catalog answered with explicit deprecations" (conditions go True).
+ //
+ // Key scenarios tested:
+ // 1. No catalog data + no bundle → all Unknown, BundleDeprecated uses reason Absent
+ // 2. No catalog data + bundle installed → all Unknown, BundleDeprecated uses reason Deprecated
+ // 3. Catalog data provided + no deprecations → all False
+ // 4. Catalog data provided + explicit deprecations → relevant conditions True
for _, tc := range []struct {
name string
clusterExtension *ocv1.ClusterExtension
expectedClusterExtension *ocv1.ClusterExtension
bundle *declcfg.Bundle
deprecation *declcfg.Deprecation
+ catalogDataProvided bool
+ hasCatalogData bool
}{
{
- name: "no deprecations, all deprecation statuses set to False",
+ name: "no catalog data, all deprecation statuses set to Unknown",
+ clusterExtension: &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 1,
+ },
+ Status: ocv1.ClusterExtensionStatus{
+ Conditions: []metav1.Condition{},
+ },
+ },
+ expectedClusterExtension: &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 1,
+ },
+ Status: ocv1.ClusterExtensionStatus{
+ Conditions: []metav1.Condition{
+ {
+ Type: ocv1.TypeDeprecated,
+ Reason: ocv1.ReasonDeprecated,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ {
+ Type: ocv1.TypePackageDeprecated,
+ Reason: ocv1.ReasonDeprecated,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ {
+ Type: ocv1.TypeChannelDeprecated,
+ Reason: ocv1.ReasonDeprecated,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ {
+ Type: ocv1.TypeBundleDeprecated,
+ Reason: ocv1.ReasonAbsent,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ },
+ },
+ },
+ bundle: &declcfg.Bundle{},
+ deprecation: nil,
+ catalogDataProvided: false,
+ hasCatalogData: false,
+ },
+ {
+ // Scenario:
+ // - A bundle is installed (v1.0.0)
+ // - Catalog becomes unavailable (removed or network failure)
+ // - No catalog data can be retrieved
+ // - BundleDeprecated must show Unknown/Deprecated (not Absent)
+ // - Reason is Deprecated because a bundle exists, Absent is only for no bundle
+ name: "no catalog data with installed bundle keeps bundle condition Unknown",
+ clusterExtension: &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 1,
+ },
+ Status: ocv1.ClusterExtensionStatus{Conditions: []metav1.Condition{}},
+ },
+ expectedClusterExtension: &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{Generation: 1},
+ Status: ocv1.ClusterExtensionStatus{Conditions: []metav1.Condition{
+ {Type: ocv1.TypeDeprecated, Reason: ocv1.ReasonDeprecated, Status: metav1.ConditionUnknown, ObservedGeneration: 1},
+ {Type: ocv1.TypePackageDeprecated, Reason: ocv1.ReasonDeprecated, Status: metav1.ConditionUnknown, ObservedGeneration: 1},
+ {Type: ocv1.TypeChannelDeprecated, Reason: ocv1.ReasonDeprecated, Status: metav1.ConditionUnknown, ObservedGeneration: 1},
+ {Type: ocv1.TypeBundleDeprecated, Reason: ocv1.ReasonDeprecated, Status: metav1.ConditionUnknown, ObservedGeneration: 1},
+ }},
+ },
+ bundle: &declcfg.Bundle{Name: "installed.v1.0.0"},
+ deprecation: nil,
+ catalogDataProvided: false,
+ hasCatalogData: false,
+ },
+ {
+ // Scenario:
+ // - A bundle is installed
+ // - Catalog returns deprecation entries but catalogDataProvided=false
+ // - This tests that deprecation data is ignored when hasCatalogData is false
+ // - All conditions go to Unknown regardless of deprecation entries present
+ // - BundleDeprecated uses Deprecated (not Absent) because bundle exists
+ name: "deprecation entries ignored when catalog data flag is false",
+ clusterExtension: &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 1,
+ },
+ Status: ocv1.ClusterExtensionStatus{
+ Conditions: []metav1.Condition{},
+ },
+ },
+ expectedClusterExtension: &ocv1.ClusterExtension{
+ ObjectMeta: metav1.ObjectMeta{
+ Generation: 1,
+ },
+ Status: ocv1.ClusterExtensionStatus{
+ Conditions: []metav1.Condition{
+ {
+ Type: ocv1.TypeDeprecated,
+ Reason: ocv1.ReasonDeprecated,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ {
+ Type: ocv1.TypePackageDeprecated,
+ Reason: ocv1.ReasonDeprecated,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ {
+ Type: ocv1.TypeChannelDeprecated,
+ Reason: ocv1.ReasonDeprecated,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ {
+ Type: ocv1.TypeBundleDeprecated,
+ Reason: ocv1.ReasonDeprecated,
+ Status: metav1.ConditionUnknown,
+ ObservedGeneration: 1,
+ },
+ },
+ },
+ },
+ bundle: &declcfg.Bundle{Name: "ignored"},
+ deprecation: &declcfg.Deprecation{Entries: []declcfg.DeprecationEntry{{
+ Reference: declcfg.PackageScopedReference{Schema: declcfg.SchemaPackage},
+ Message: "should not surface",
+ }}},
+ catalogDataProvided: true,
+ hasCatalogData: false,
+ },
+ {
+ name: "catalog consulted but no deprecations, statuses set to False",
clusterExtension: &ocv1.ClusterExtension{
ObjectMeta: metav1.ObjectMeta{
Generation: 1,
@@ -887,15 +1303,17 @@ func TestSetDeprecationStatus(t *testing.T) {
},
{
Type: ocv1.TypeBundleDeprecated,
- Reason: ocv1.ReasonDeprecated,
- Status: metav1.ConditionFalse,
+ Reason: ocv1.ReasonAbsent,
+ Status: metav1.ConditionUnknown,
ObservedGeneration: 1,
},
},
},
},
- bundle: &declcfg.Bundle{},
- deprecation: nil,
+ bundle: &declcfg.Bundle{},
+ deprecation: nil,
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
{
name: "deprecated channel, but no channel specified, all deprecation statuses set to False",
@@ -945,8 +1363,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
{
Type: ocv1.TypeBundleDeprecated,
- Reason: ocv1.ReasonDeprecated,
- Status: metav1.ConditionFalse,
+ Reason: ocv1.ReasonAbsent,
+ Status: metav1.ConditionUnknown,
ObservedGeneration: 1,
},
},
@@ -961,6 +1379,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
}},
},
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
{
name: "deprecated channel, but a non-deprecated channel specified, all deprecation statuses set to False",
@@ -1014,8 +1434,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
{
Type: ocv1.TypeBundleDeprecated,
- Reason: ocv1.ReasonDeprecated,
- Status: metav1.ConditionFalse,
+ Reason: ocv1.ReasonAbsent,
+ Status: metav1.ConditionUnknown,
ObservedGeneration: 1,
},
},
@@ -1032,6 +1452,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
},
},
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
{
name: "deprecated channel specified, ChannelDeprecated and Deprecated status set to true, others set to false",
@@ -1085,8 +1507,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
{
Type: ocv1.TypeBundleDeprecated,
- Reason: ocv1.ReasonDeprecated,
- Status: metav1.ConditionFalse,
+ Reason: ocv1.ReasonAbsent,
+ Status: metav1.ConditionUnknown,
ObservedGeneration: 1,
},
},
@@ -1104,6 +1526,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
},
},
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
{
name: "deprecated package and channel specified, deprecated bundle, all deprecation statuses set to true",
@@ -1189,6 +1613,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
},
},
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
{
name: "deprecated channel specified, deprecated bundle, all deprecation statuses set to true, all deprecation statuses set to true except PackageDeprecated",
@@ -1268,6 +1694,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
},
},
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
{
name: "deprecated package and channel specified, all deprecation statuses set to true except BundleDeprecated",
@@ -1321,8 +1749,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
{
Type: ocv1.TypeBundleDeprecated,
- Reason: ocv1.ReasonDeprecated,
- Status: metav1.ConditionFalse,
+ Reason: ocv1.ReasonAbsent,
+ Status: metav1.ConditionUnknown,
ObservedGeneration: 1,
},
},
@@ -1346,6 +1774,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
},
},
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
{
name: "deprecated channels specified, ChannelDeprecated and Deprecated status set to true, others set to false",
@@ -1399,8 +1829,8 @@ func TestSetDeprecationStatus(t *testing.T) {
},
{
Type: ocv1.TypeBundleDeprecated,
- Reason: ocv1.ReasonDeprecated,
- Status: metav1.ConditionFalse,
+ Reason: ocv1.ReasonAbsent,
+ Status: metav1.ConditionUnknown,
ObservedGeneration: 1,
},
},
@@ -1421,14 +1851,22 @@ func TestSetDeprecationStatus(t *testing.T) {
Schema: declcfg.SchemaChannel,
Name: "anotherbadchannel",
},
- Message: "another bad channedl!",
+ Message: "another bad channel!",
},
},
},
+ catalogDataProvided: true,
+ hasCatalogData: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
- controllers.SetDeprecationStatus(tc.clusterExtension, tc.bundle.Name, tc.deprecation)
+ // When a test provides deprecation data it must also explicitly state that the catalog responded.
+ // This guard keeps future cases from silently falling back to the "catalog absent" branch.
+ if tc.deprecation != nil && !tc.catalogDataProvided {
+ require.Failf(t, "test case must set catalogDataProvided when deprecation is supplied", "test case %q", tc.name)
+ }
+ hasCatalogData := tc.catalogDataProvided && tc.hasCatalogData
+ controllers.SetDeprecationStatus(tc.clusterExtension, tc.bundle.Name, tc.deprecation, hasCatalogData)
// TODO: we should test for unexpected changes to lastTransitionTime. We only expect
// lastTransitionTime to change when the status of the condition changes.
assert.Empty(t, cmp.Diff(tc.expectedClusterExtension, tc.clusterExtension, cmpopts.IgnoreFields(metav1.Condition{}, "Message", "LastTransitionTime")))
diff --git a/internal/operator-controller/controllers/common_controller_test.go b/internal/operator-controller/controllers/common_controller_test.go
index 4d0a0536d..93fad962e 100644
--- a/internal/operator-controller/controllers/common_controller_test.go
+++ b/internal/operator-controller/controllers/common_controller_test.go
@@ -146,7 +146,7 @@ func TestClusterExtensionDeprecationMessageTruncation(t *testing.T) {
deprecationMessages = append(deprecationMessages, fmt.Sprintf("API version 'v1beta1' of resource 'customresources%d.example.com' is deprecated, use 'v1' instead", i))
}
- longDeprecationMsg := strings.Join(deprecationMessages, "; ")
+ longDeprecationMsg := strings.Join(deprecationMessages, "\n")
setInstalledStatusConditionUnknown(ext, longDeprecationMsg)
cond := meta.FindStatusCondition(ext.Status.Conditions, ocv1.TypeInstalled)
diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml
index 1efa8b8d9..c53596894 100644
--- a/manifests/experimental-e2e.yaml
+++ b/manifests/experimental-e2e.yaml
@@ -1323,12 +1323,15 @@ spec:
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
- When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
+ When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
- BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
- ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
- PackageDeprecated is set if the requested package is marked deprecated in the catalog.
- Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
+ PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
+ about the package, the condition stays Unknown instead of claiming False.
+ ChannelDeprecated follows the same rules for each requested channel.
+ BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
+ data is available, it remains Unknown.
+ Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
+ when we have no catalog information to report.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml
index 664f8599c..52777d22c 100644
--- a/manifests/experimental.yaml
+++ b/manifests/experimental.yaml
@@ -1288,12 +1288,15 @@ spec:
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
- When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
+ When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
- BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
- ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
- PackageDeprecated is set if the requested package is marked deprecated in the catalog.
- Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
+ PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
+ about the package, the condition stays Unknown instead of claiming False.
+ ChannelDeprecated follows the same rules for each requested channel.
+ BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
+ data is available, it remains Unknown.
+ Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
+ when we have no catalog information to report.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml
index 783beec51..968e9279b 100644
--- a/manifests/standard-e2e.yaml
+++ b/manifests/standard-e2e.yaml
@@ -1070,12 +1070,15 @@ spec:
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
- When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
+ When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
- BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
- ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
- PackageDeprecated is set if the requested package is marked deprecated in the catalog.
- Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
+ PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
+ about the package, the condition stays Unknown instead of claiming False.
+ ChannelDeprecated follows the same rules for each requested channel.
+ BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
+ data is available, it remains Unknown.
+ Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
+ when we have no catalog information to report.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
diff --git a/manifests/standard.yaml b/manifests/standard.yaml
index 95e400c26..3a770c888 100644
--- a/manifests/standard.yaml
+++ b/manifests/standard.yaml
@@ -1035,12 +1035,15 @@ spec:
When Progressing is True and the Reason is Retrying, the ClusterExtension has encountered an error that could be resolved on subsequent reconciliation attempts.
When Progressing is False and the Reason is Blocked, the ClusterExtension has encountered an error that requires manual intervention for recovery.
- When the ClusterExtension is sourced from a catalog, if may also communicate a deprecation condition.
+ When the ClusterExtension is sourced from a catalog, it may surface deprecation conditions based on catalog metadata.
These are indications from a package owner to guide users away from a particular package, channel, or bundle.
- BundleDeprecated is set if the requested bundle version is marked deprecated in the catalog.
- ChannelDeprecated is set if the requested channel is marked deprecated in the catalog.
- PackageDeprecated is set if the requested package is marked deprecated in the catalog.
- Deprecated is a rollup condition that is present when any of the deprecated conditions are present.
+ PackageDeprecated reports the requested package as deprecated when the catalog says so. When no catalog talks
+ about the package, the condition stays Unknown instead of claiming False.
+ ChannelDeprecated follows the same rules for each requested channel.
+ BundleDeprecated describes the installed bundle once one exists. Until a bundle installs, or when no catalog
+ data is available, it remains Unknown.
+ Deprecated is still the rollup: it goes True when any individual deprecation condition is True, and Unknown
+ when we have no catalog information to report.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
diff --git a/test/e2e/cluster_extension_install_test.go b/test/e2e/cluster_extension_install_test.go
index ab0bf48b1..9f79baf6b 100644
--- a/test/e2e/cluster_extension_install_test.go
+++ b/test/e2e/cluster_extension_install_test.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
+ "slices"
"testing"
"time"
@@ -656,6 +657,12 @@ func TestClusterExtensionRecoversFromNoNamespaceWhenFailureFixed(t *testing.T) {
require.Equal(ct, ocv1.ReasonSucceeded, cond.Reason)
require.Contains(ct, cond.Message, "Installed bundle")
require.NotEmpty(ct, clusterExtension.Status.Install)
+
+ bundleDeprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeBundleDeprecated)
+ require.NotNil(ct, bundleDeprecatedCond)
+ require.Equal(ct, metav1.ConditionFalse, bundleDeprecatedCond.Status)
+ require.Equal(ct, ocv1.ReasonDeprecated, bundleDeprecatedCond.Reason)
+ require.Empty(ct, bundleDeprecatedCond.Message)
}, pollDuration, pollInterval)
t.Log("By eventually reporting Progressing == True with Reason Success")
@@ -755,11 +762,23 @@ func TestClusterExtensionRecoversFromExistingDeploymentWhenFailureFixed(t *testi
cond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeInstalled)
require.NotNil(ct, cond)
require.Equal(ct, metav1.ConditionFalse, cond.Status)
- // TODO: We probably _should_ be testing the reason here, but helm and boxcutter applier have different reasons.
- // Maybe we change helm to use "Absent" rather than "Failed" since the Progressing condition already captures
- // the failure?
- //require.Equal(ct, ocv1.ReasonFailed, cond.Reason)
+ // Helm uses Failed, Boxcutter uses Absent; both are fine here.
+ require.True(ct, slices.Contains([]string{ocv1.ReasonFailed, ocv1.ReasonAbsent}, cond.Reason))
require.Contains(ct, cond.Message, "No bundle installed")
+
+ deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated)
+ require.NotNil(ct, deprecatedCond)
+ // Deprecation status depends on runtime:
+ // - Helm: False (catalog lookup happens during resolution)
+ // - Boxcutter: Unknown (uses RollingOut path, no catalog lookup)
+ require.True(ct, deprecatedCond.Status == metav1.ConditionFalse || deprecatedCond.Status == metav1.ConditionUnknown)
+ require.Empty(ct, deprecatedCond.Message)
+
+ bundleDeprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeBundleDeprecated)
+ require.NotNil(ct, bundleDeprecatedCond)
+ require.Equal(ct, metav1.ConditionUnknown, bundleDeprecatedCond.Status)
+ require.Equal(ct, ocv1.ReasonAbsent, bundleDeprecatedCond.Reason)
+ require.Empty(ct, bundleDeprecatedCond.Message)
}, pollDuration, pollInterval)
t.Log("By deleting the new Deployment")