From 8b6cfefb68560c6494d8a8440f6f54007f9f8518 Mon Sep 17 00:00:00 2001 From: Camila Macedo <7708031+camilamacedo86@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:54:52 +0000 Subject: [PATCH] Fix deprecation conditions and make YAML output cleaner Deprecation conditions now only show what matters: - When deprecated: condition shows "True" with deprecation message - When not deprecated: condition is absent (no clutter!) - When we can't check: condition shows "Unknown" This fixes three problems: 1. Install errors were leaking into deprecation conditions 2. Catalog unavailable showed "False" instead of "Unknown" 3. BundleDeprecated was checking the wrong bundle (resolved vs installed) Also improved the code: - Simpler logic (clear all, then add only what's needed) - Better reason values (Deprecated, DeprecationStatusUnknown, Absent) - Comprehensive test coverage for all scenarios Assisted-by: Cursor --- api/v1/clusterextension_types.go | 13 +- api/v1/common_types.go | 3 +- docs/api-reference/olmv1-api-reference.md | 2 +- ...peratorframework.io_clusterextensions.yaml | 13 +- ...peratorframework.io_clusterextensions.yaml | 13 +- .../conditionsets/conditionsets.go | 1 + .../clusterextension_admission_test.go | 28 +- .../clusterextension_controller.go | 189 ++++-- .../clusterextension_controller_test.go | 577 +++++++++++++++--- .../clusterextension_reconcile_steps.go | 93 +-- .../controllers/common_controller_test.go | 2 +- manifests/experimental-e2e.yaml | 13 +- manifests/experimental.yaml | 13 +- manifests/standard-e2e.yaml | 13 +- manifests/standard.yaml | 13 +- 15 files changed, 738 insertions(+), 248 deletions(-) diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index f098d2220..8e9256427 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -488,12 +488,13 @@ type ClusterExtensionStatus struct { // When Progressing is True and Reason is RollingOut, the ClusterExtension has one or more ClusterExtensionRevisions in active roll out. // // - // When the ClusterExtension is sourced from a catalog, it 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. + // 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. + // Deprecation conditions are only present when there's something to report - absence means "not deprecated". + // - BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet. + // - ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found. + // - PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found. + // - Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). // // +listType=map // +listMapKey=type diff --git a/api/v1/common_types.go b/api/v1/common_types.go index 115836b10..b63ca9a95 100644 --- a/api/v1/common_types.go +++ b/api/v1/common_types.go @@ -29,7 +29,8 @@ const ( ReasonBlocked = "Blocked" // Deprecation reasons - ReasonDeprecated = "Deprecated" + ReasonDeprecated = "Deprecated" + ReasonDeprecationStatusUnknown = "DeprecationStatusUnknown" // Common reasons ReasonSucceeded = "Succeeded" diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md index 6aeb4c8f4..2fc64da8c 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 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 Progressing is True and Reason is RollingOut, the ClusterExtension has one or more ClusterExtensionRevisions in active roll out.

When the ClusterExtension is sourced from a catalog, it 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 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 Progressing is True and Reason is RollingOut, the ClusterExtension has one or more ClusterExtensionRevisions in active roll out.

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.
Deprecation conditions are only present when there's something to report - absence means "not deprecated".
- BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet.
- ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found.
- PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found.
- Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). | | | | `install` _[ClusterExtensionInstallStatus](#clusterextensioninstallstatus)_ | install is a representation of the current installation status for this ClusterExtension. | | | | `activeRevisions` _[RevisionStatus](#revisionstatus) array_ | activeRevisions holds a list of currently active (non-archived) ClusterExtensionRevisions,
including both installed and rolling out revisions.
| | | 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 55687b567..5489ef69e 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 @@ -589,12 +589,13 @@ spec: When Progressing is True and Reason is RollingOut, the ClusterExtension has one or more ClusterExtensionRevisions in active roll out. - When the ClusterExtension is sourced from a catalog, it 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. + 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. + Deprecation conditions are only present when there's something to report - absence means "not deprecated". + - BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet. + - ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found. + - PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found. + - Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). 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 e1316237c..c28fd97e2 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 @@ -467,12 +467,13 @@ 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, it 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. + 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. + Deprecation conditions are only present when there's something to report - absence means "not deprecated". + - BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet. + - ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found. + - PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found. + - Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). items: description: Condition contains details for one aspect of the current state of this API Resource. diff --git a/internal/operator-controller/conditionsets/conditionsets.go b/internal/operator-controller/conditionsets/conditionsets.go index 6c33b1c8f..77b11fe46 100644 --- a/internal/operator-controller/conditionsets/conditionsets.go +++ b/internal/operator-controller/conditionsets/conditionsets.go @@ -36,6 +36,7 @@ var ConditionTypes = []string{ var ConditionReasons = []string{ ocv1.ReasonSucceeded, ocv1.ReasonDeprecated, + ocv1.ReasonDeprecationStatusUnknown, ocv1.ReasonFailed, ocv1.ReasonBlocked, ocv1.ReasonRetrying, diff --git a/internal/operator-controller/controllers/clusterextension_admission_test.go b/internal/operator-controller/controllers/clusterextension_admission_test.go index 2f8791999..d98d9bf4f 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 7f3192b0f..5a5f167f7 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "io/fs" + "slices" "strings" "github.com/go-logr/logr" @@ -166,13 +167,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, @@ -184,83 +192,154 @@ func ensureAllConditionsWithReason(ext *ocv1.ClusterExtension, reason v1alpha1.C } } -// 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{} +// SetDeprecationStatus updates deprecation conditions based on catalog metadata. +// +// Behavior: +// - IS deprecated → condition True with Reason: Deprecated +// - NOT deprecated → condition absent (clean YAML) +// - Can't check (no catalog) → condition Unknown with Reason: DeprecationStatusUnknown +// - No bundle installed → BundleDeprecated Unknown with Reason: Absent +// +// This keeps deprecation conditions focused on catalog data. Install/validation errors +// never appear here - they belong in Progressing/Installed conditions. +// +// TODO (out of scope): What if different catalogs have conflicting deprecation data? +// +// Example scenario: +// Catalog A: package "foo" marked deprecated +// Catalog B: package "foo" NOT deprecated +// Problem: Resolver picks one catalog arbitrarily when resolution fails +// Question: Should we mark Unknown? Combine all? Pick by priority? +// This needs follow-up discussion and PR. +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) + + // Clear all deprecation conditions first, then only add the ones we need. + // Absence of a deprecation condition means "not deprecated" - keeps output clean. + apimeta.RemoveStatusCondition(&ext.Status.Conditions, ocv1.TypeDeprecated) + apimeta.RemoveStatusCondition(&ext.Status.Conditions, ocv1.TypePackageDeprecated) + apimeta.RemoveStatusCondition(&ext.Status.Conditions, ocv1.TypeChannelDeprecated) + apimeta.RemoveStatusCondition(&ext.Status.Conditions, ocv1.TypeBundleDeprecated) + + if !hasCatalogData { + // When catalog is unavailable, set all to Unknown. + // BundleDeprecated uses Absent only when no bundle installed. + bundleReason := ocv1.ReasonAbsent + if installedBundleName != "" { + bundleReason = ocv1.ReasonDeprecationStatusUnknown + } + setDeprecationCondition(ext, ocv1.TypeDeprecated, metav1.ConditionUnknown, ocv1.ReasonDeprecationStatusUnknown, "") + setDeprecationCondition(ext, ocv1.TypePackageDeprecated, metav1.ConditionUnknown, ocv1.ReasonDeprecationStatusUnknown, "") + setDeprecationCondition(ext, ocv1.TypeChannelDeprecated, metav1.ConditionUnknown, ocv1.ReasonDeprecationStatusUnknown, "") + setDeprecationCondition(ext, ocv1.TypeBundleDeprecated, metav1.ConditionUnknown, bundleReason, "") + return + } + + // Only add conditions when there's something to report (True or Unknown states). + // False (not deprecated) is represented by absence of the condition. + messages := slices.Concat(packageMessages, channelMessages, bundleMessages) + if len(messages) > 0 { + setDeprecationCondition(ext, ocv1.TypeDeprecated, metav1.ConditionTrue, ocv1.ReasonDeprecated, strings.Join(messages, "\n")) + } + + if len(packageMessages) > 0 { + setDeprecationCondition(ext, ocv1.TypePackageDeprecated, metav1.ConditionTrue, ocv1.ReasonDeprecated, strings.Join(packageMessages, "\n")) + } + + if len(channelMessages) > 0 { + setDeprecationCondition(ext, ocv1.TypeChannelDeprecated, metav1.ConditionTrue, ocv1.ReasonDeprecated, strings.Join(channelMessages, "\n")) + } + + // BundleDeprecated: Unknown when no bundle installed, True when deprecated, absent otherwise + if info.BundleStatus == metav1.ConditionUnknown { + setDeprecationCondition(ext, ocv1.TypeBundleDeprecated, metav1.ConditionUnknown, ocv1.ReasonAbsent, "") + } else if len(bundleMessages) > 0 { + setDeprecationCondition(ext, ocv1.TypeBundleDeprecated, metav1.ConditionTrue, ocv1.ReasonDeprecated, strings.Join(bundleMessages, "\n")) + } +} + +// 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 + } +} + +// 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 +} + +// 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, ";") - } + return info +} + +// 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: ocv1.TypeDeprecated, - Reason: reason, + Type: condType, Status: status, + Reason: reason, Message: message, - ObservedGeneration: ext.Generation, + ObservedGeneration: ext.GetGeneration(), }) +} - // 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 } 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 1b09a43ad..16f79a307 100644 --- a/internal/operator-controller/controllers/clusterextension_controller_test.go +++ b/internal/operator-controller/controllers/clusterextension_controller_test.go @@ -32,7 +32,8 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets" "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" - finalizers "github.com/operator-framework/operator-controller/internal/operator-controller/finalizers" + "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" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" @@ -127,7 +128,7 @@ func TestClusterExtensionShortCircuitsReconcileDuringDeletion(t *testing.T) { func TestClusterExtensionResolutionFails(t *testing.T) { pkgName := fmt.Sprintf("non-existent-%s", rand.String(6)) cl, reconciler := newClientAndReconciler(t, func(d *deps) { - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { return nil, nil, nil, fmt.Errorf("no package %q found", pkgName) }) }) @@ -177,6 +178,145 @@ 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() + pkgName := fmt.Sprintf("deprecated-%s", rand.String(6)) + deprecationMessage := "package marked deprecated in catalog" + cl, reconciler := newClientAndReconciler(t, func(d *deps) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *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() + pkgName := fmt.Sprintf("missing-%s", rand.String(6)) + installedBundleName := fmt.Sprintf("%s.v1.0.0", pkgName) + cl, reconciler := newClientAndReconciler(t, func(d *deps) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + return nil, nil, nil, fmt.Errorf("no bundles found for package %q", pkgName) + }) + + d.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.ReasonDeprecationStatusUnknown, 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.ReasonDeprecationStatusUnknown, 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.ReasonDeprecationStatusUnknown, 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 @@ -230,7 +370,7 @@ func TestClusterExtensionResolutionSuccessfulUnpackFails(t *testing.T) { } }, func(d *deps) { - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { v := bundle.VersionRelease{ Version: bsemver.MustParse("1.0.0"), } @@ -277,6 +417,19 @@ 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") + // When not deprecated, conditions are absent (cleaner output) + deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated) + require.Nil(t, deprecatedCond, "Deprecated condition should be absent when not deprecated") + pkgCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypePackageDeprecated) + require.Nil(t, pkgCond, "PackageDeprecated condition should be absent when not deprecated") + chanCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeChannelDeprecated) + require.Nil(t, chanCond, "ChannelDeprecated condition should be absent when not deprecated") + 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{})) }) } @@ -288,7 +441,7 @@ func TestClusterExtensionResolutionAndUnpackSuccessfulApplierFails(t *testing.T) d.ImagePuller = &imageutil.MockPuller{ ImageFS: fstest.MapFS{}, } - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { v := bundle.VersionRelease{ Version: bsemver.MustParse("1.0.0"), } @@ -361,6 +514,126 @@ 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") + // When not deprecated, conditions are absent (cleaner output) + deprecatedCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeDeprecated) + require.Nil(t, deprecatedCond, "Deprecated condition should be absent when not deprecated") + pkgCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypePackageDeprecated) + require.Nil(t, pkgCond, "PackageDeprecated condition should be absent when not deprecated") + chanCond := apimeta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeChannelDeprecated) + require.Nil(t, chanCond, "ChannelDeprecated condition should be absent when not deprecated") + 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, func(d *deps) { + // Boxcutter keeps a rolling revision when apply fails. We mirror that state so the test uses + // the same inputs the runtime would see. + d.RevisionStatesGetter = &MockRevisionStatesGetter{ + RevisionStates: &controllers.RevisionStates{ + RollingOut: []*controllers.RevisionMetadata{{}}, + }, + } + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + v := bundle.VersionRelease{ + Version: bsemver.MustParse("1.0.0"), + } + return &declcfg.Bundle{ + Name: "prometheus.v1.0.0", + Package: "prometheus", + Image: "quay.io/operatorhubio/prometheus@fake1.0.0", + }, &v, nil, nil + }) + d.ImagePuller = &imageutil.MockPuller{ImageFS: fstest.MapFS{}} + d.Applier = &MockApplier{err: errors.New("boxcutter apply failure")} + }) + + 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)) + + 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.ReasonDeprecationStatusUnknown, 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.ReasonDeprecationStatusUnknown, 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.ReasonDeprecationStatusUnknown, 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{})) } @@ -428,7 +701,7 @@ func TestClusterExtensionApplierFailsWithBundleInstalled(t *testing.T) { d.ImagePuller = &imageutil.MockPuller{ ImageFS: fstest.MapFS{}, } - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { v := bundle.VersionRelease{ Version: bsemver.MustParse("1.0.0"), } @@ -523,7 +796,7 @@ func TestClusterExtensionManagerFailed(t *testing.T) { d.ImagePuller = &imageutil.MockPuller{ ImageFS: fstest.MapFS{}, } - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { v := bundle.VersionRelease{ Version: bsemver.MustParse("1.0.0"), } @@ -602,7 +875,7 @@ func TestClusterExtensionManagedContentCacheWatchFail(t *testing.T) { d.ImagePuller = &imageutil.MockPuller{ ImageFS: fstest.MapFS{}, } - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { v := bundle.VersionRelease{ Version: bsemver.MustParse("1.0.0"), } @@ -683,7 +956,7 @@ func TestClusterExtensionInstallationSucceeds(t *testing.T) { d.ImagePuller = &imageutil.MockPuller{ ImageFS: fstest.MapFS{}, } - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { v := bundle.VersionRelease{ Version: bsemver.MustParse("1.0.0"), } @@ -764,7 +1037,7 @@ func TestClusterExtensionDeleteFinalizerFails(t *testing.T) { d.ImagePuller = &imageutil.MockPuller{ ImageFS: fstest.MapFS{}, } - d.Resolver = resolve.Func(func(_ context.Context, _ *ocv1.ClusterExtension, _ *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { + d.Resolver = resolve.Func(func(ctx context.Context, ext *ocv1.ClusterExtension, installedBundle *ocv1.BundleMetadata) (*declcfg.Bundle, *bundle.VersionRelease, *declcfg.Deprecation, error) { v := bundle.VersionRelease{ Version: bsemver.MustParse("1.0.0"), } @@ -867,30 +1140,55 @@ func verifyInvariants(ctx context.Context, t *testing.T, c client.Client, ext *o } func verifyConditionsInvariants(t *testing.T, ext *ocv1.ClusterExtension) { - // Expect that the cluster extension's set of conditions contains all defined - // condition types for the ClusterExtension API. Every reconcile should always - // ensure every condition type's status/reason/message reflects the state - // read during _this_ reconcile call. - require.Len(t, ext.Status.Conditions, len(conditionsets.ConditionTypes)) - for _, tt := range conditionsets.ConditionTypes { + // Core conditions (Installed, Progressing) must always be present. + // Deprecation conditions are optional - absence means "not deprecated". + coreConditions := []string{ocv1.TypeInstalled, ocv1.TypeProgressing} + deprecationConditions := []string{ocv1.TypeDeprecated, ocv1.TypePackageDeprecated, ocv1.TypeChannelDeprecated, ocv1.TypeBundleDeprecated} + + for _, tt := range coreConditions { cond := apimeta.FindStatusCondition(ext.Status.Conditions, tt) - require.NotNil(t, cond) + require.NotNil(t, cond, "core condition %s must be present", tt) require.NotEmpty(t, cond.Status) require.Contains(t, conditionsets.ConditionReasons, cond.Reason) require.Equal(t, ext.GetGeneration(), cond.ObservedGeneration) } + + // Deprecation conditions are optional, but if present must be valid + for _, tt := range deprecationConditions { + cond := apimeta.FindStatusCondition(ext.Status.Conditions, tt) + if cond != nil { + require.NotEmpty(t, cond.Status) + require.Contains(t, conditionsets.ConditionReasons, cond.Reason) + require.Equal(t, ext.GetGeneration(), cond.ObservedGeneration) + } + } } 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 absent, except + // BundleDeprecated which remains Unknown when no bundle is installed), 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 DeprecationStatusUnknown + // 3. Catalog data provided + no deprecations → deprecation conditions absent except + // BundleDeprecated remains Unknown when no bundle is installed + // 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, @@ -907,45 +1205,75 @@ func TestSetDeprecationStatus(t *testing.T) { Conditions: []metav1.Condition{ { Type: ocv1.TypeDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonDeprecationStatusUnknown, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, { Type: ocv1.TypePackageDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonDeprecationStatusUnknown, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, { Type: ocv1.TypeChannelDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonDeprecationStatusUnknown, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, { 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: false, + hasCatalogData: false, }, { - name: "deprecated channel, but no channel specified, all deprecation statuses set to 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/DeprecationStatusUnknown (not Absent) + // - Reason is DeprecationStatusUnknown because catalog data is unavailable; 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, }, - Spec: ocv1.ClusterExtensionSpec{ - Source: ocv1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1.CatalogFilter{}, - }, + 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.ReasonDeprecationStatusUnknown, Status: metav1.ConditionUnknown, ObservedGeneration: 1}, + {Type: ocv1.TypePackageDeprecated, Reason: ocv1.ReasonDeprecationStatusUnknown, Status: metav1.ConditionUnknown, ObservedGeneration: 1}, + {Type: ocv1.TypeChannelDeprecated, Reason: ocv1.ReasonDeprecationStatusUnknown, Status: metav1.ConditionUnknown, ObservedGeneration: 1}, + {Type: ocv1.TypeBundleDeprecated, Reason: ocv1.ReasonDeprecationStatusUnknown, 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 DeprecationStatusUnknown (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{}, @@ -955,36 +1283,105 @@ func TestSetDeprecationStatus(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Generation: 1, }, - Spec: ocv1.ClusterExtensionSpec{ - Source: ocv1.SourceConfig{ - SourceType: "Catalog", - Catalog: &ocv1.CatalogFilter{}, - }, - }, Status: ocv1.ClusterExtensionStatus{ Conditions: []metav1.Condition{ { Type: ocv1.TypeDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonDeprecationStatusUnknown, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, { Type: ocv1.TypePackageDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonDeprecationStatusUnknown, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, { Type: ocv1.TypeChannelDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonDeprecationStatusUnknown, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, { Type: ocv1.TypeBundleDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonDeprecationStatusUnknown, + 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, conditions absent except BundleDeprecated Unknown when no bundle", + 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.TypeBundleDeprecated, + Reason: ocv1.ReasonAbsent, + Status: metav1.ConditionUnknown, + ObservedGeneration: 1, + }, + }, + }, + }, + bundle: &declcfg.Bundle{}, + deprecation: nil, + catalogDataProvided: true, + hasCatalogData: true, + }, + { + name: "deprecated channel exists but not used, conditions absent except BundleDeprecated Unknown", + clusterExtension: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1.ClusterExtensionSpec{ + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{}, + }, + }, + Status: ocv1.ClusterExtensionStatus{ + Conditions: []metav1.Condition{}, + }, + }, + expectedClusterExtension: &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Spec: ocv1.ClusterExtensionSpec{ + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{}, + }, + }, + Status: ocv1.ClusterExtensionStatus{ + Conditions: []metav1.Condition{ + { + Type: ocv1.TypeBundleDeprecated, + Reason: ocv1.ReasonAbsent, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, }, @@ -999,9 +1396,11 @@ func TestSetDeprecationStatus(t *testing.T) { }, }}, }, + catalogDataProvided: true, + hasCatalogData: true, }, { - name: "deprecated channel, but a non-deprecated channel specified, all deprecation statuses set to False", + name: "deprecated channel exists but non-deprecated channel specified; conditions absent except BundleDeprecated Unknown", clusterExtension: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Generation: 1, @@ -1032,28 +1431,10 @@ func TestSetDeprecationStatus(t *testing.T) { }, Status: ocv1.ClusterExtensionStatus{ Conditions: []metav1.Condition{ - { - Type: ocv1.TypeDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1.TypePackageDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, - { - Type: ocv1.TypeChannelDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, { Type: ocv1.TypeBundleDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonAbsent, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, }, @@ -1070,9 +1451,11 @@ func TestSetDeprecationStatus(t *testing.T) { }, }, }, + catalogDataProvided: true, + hasCatalogData: true, }, { - name: "deprecated channel specified, ChannelDeprecated and Deprecated status set to true, others set to false", + name: "deprecated channel specified, ChannelDeprecated and Deprecated set to true, PackageDeprecated absent, BundleDeprecated Unknown", clusterExtension: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Generation: 1, @@ -1109,12 +1492,6 @@ func TestSetDeprecationStatus(t *testing.T) { Status: metav1.ConditionTrue, ObservedGeneration: 1, }, - { - Type: ocv1.TypePackageDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, { Type: ocv1.TypeChannelDeprecated, Reason: ocv1.ReasonDeprecated, @@ -1123,8 +1500,8 @@ func TestSetDeprecationStatus(t *testing.T) { }, { Type: ocv1.TypeBundleDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonAbsent, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, }, @@ -1142,6 +1519,8 @@ func TestSetDeprecationStatus(t *testing.T) { }, }, }, + catalogDataProvided: true, + hasCatalogData: true, }, { name: "deprecated package and channel specified, deprecated bundle, all deprecation statuses set to true", @@ -1227,9 +1606,11 @@ 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", + name: "deprecated channel and bundle specified, Deprecated/ChannelDeprecated/BundleDeprecated set to true, PackageDeprecated absent", clusterExtension: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Generation: 1, @@ -1266,12 +1647,6 @@ func TestSetDeprecationStatus(t *testing.T) { Status: metav1.ConditionTrue, ObservedGeneration: 1, }, - { - Type: ocv1.TypePackageDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, { Type: ocv1.TypeChannelDeprecated, Reason: ocv1.ReasonDeprecated, @@ -1306,9 +1681,11 @@ func TestSetDeprecationStatus(t *testing.T) { }, }, }, + catalogDataProvided: true, + hasCatalogData: true, }, { - name: "deprecated package and channel specified, all deprecation statuses set to true except BundleDeprecated", + name: "deprecated package and channel specified, Deprecated/PackageDeprecated/ChannelDeprecated set to true, BundleDeprecated Unknown/Absent (no bundle installed)", clusterExtension: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Generation: 1, @@ -1359,8 +1736,8 @@ func TestSetDeprecationStatus(t *testing.T) { }, { Type: ocv1.TypeBundleDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonAbsent, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, }, @@ -1384,9 +1761,11 @@ func TestSetDeprecationStatus(t *testing.T) { }, }, }, + catalogDataProvided: true, + hasCatalogData: true, }, { - name: "deprecated channels specified, ChannelDeprecated and Deprecated status set to true, others set to false", + name: "deprecated channels specified, ChannelDeprecated and Deprecated set to true, PackageDeprecated absent, BundleDeprecated Unknown/Absent", clusterExtension: &ocv1.ClusterExtension{ ObjectMeta: metav1.ObjectMeta{ Generation: 1, @@ -1423,12 +1802,6 @@ func TestSetDeprecationStatus(t *testing.T) { Status: metav1.ConditionTrue, ObservedGeneration: 1, }, - { - Type: ocv1.TypePackageDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, - ObservedGeneration: 1, - }, { Type: ocv1.TypeChannelDeprecated, Reason: ocv1.ReasonDeprecated, @@ -1437,8 +1810,8 @@ func TestSetDeprecationStatus(t *testing.T) { }, { Type: ocv1.TypeBundleDeprecated, - Reason: ocv1.ReasonDeprecated, - Status: metav1.ConditionFalse, + Reason: ocv1.ReasonAbsent, + Status: metav1.ConditionUnknown, ObservedGeneration: 1, }, }, @@ -1459,14 +1832,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/clusterextension_reconcile_steps.go b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go index 09dd2ce7d..94ea92d40 100644 --- a/internal/operator-controller/controllers/clusterextension_reconcile_steps.go +++ b/internal/operator-controller/controllers/clusterextension_reconcile_steps.go @@ -86,50 +86,63 @@ func RetrieveRevisionStates(r RevisionStatesGetter) ReconcileStepFunc { func ResolveBundle(r resolve.Resolver) ReconcileStepFunc { return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) { l := log.FromContext(ctx) - var resolvedRevisionMetadata *RevisionMetadata - if len(state.revisionStates.RollingOut) == 0 { - l.Info("resolving bundle") - var bm *ocv1.BundleMetadata + + // If already rolling out, use existing revision and set deprecation to Unknown (no catalog check) + if len(state.revisionStates.RollingOut) > 0 { + installedBundleName := "" if state.revisionStates.Installed != nil { - bm = &state.revisionStates.Installed.BundleMetadata - } - resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolve(ctx, ext, bm) - if err != nil { - // Note: We don't distinguish between resolution-specific errors and generic errors - setStatusProgressing(ext, err) - setInstalledStatusFromRevisionStates(ext, state.revisionStates) - ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error()) - return nil, err + installedBundleName = state.revisionStates.Installed.Name } + SetDeprecationStatus(ext, installedBundleName, nil, false) + state.resolvedRevisionMetadata = state.revisionStates.RollingOut[0] + return nil, nil + } - // 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, - // TODO: Right now, operator-controller only supports registry+v1 bundles and has no concept - // of a "release" field. If/when we add a release field concept or a new bundle format - // we need to re-evaluate use of `AsLegacyRegistryV1Version` so that we avoid propagating - // registry+v1's semver spec violations of treating build metadata as orderable. - BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, resolvedBundleVersion.AsLegacyRegistryV1Version()), - } - } else { - resolvedRevisionMetadata = state.revisionStates.RollingOut[0] + // Resolve a new bundle from the catalog + l.Info("resolving bundle") + var bm *ocv1.BundleMetadata + if state.revisionStates.Installed != nil { + bm = &state.revisionStates.Installed.BundleMetadata + } + resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolve(ctx, ext, bm) + + // Get the installed bundle name for deprecation status. + // BundleDeprecated should reflect what's currently running, not what we're trying to install. + installedBundleName := "" + if state.revisionStates.Installed != nil { + installedBundleName = state.revisionStates.Installed.Name + } + + // Set deprecation status based on resolution results: + // - If resolution succeeds: hasCatalogData=true, deprecation shows catalog data (nil=not deprecated) + // - If resolution fails but returns deprecation: hasCatalogData=true, show package/channel deprecation warnings + // - If resolution fails with nil deprecation: hasCatalogData=false, all conditions go Unknown + // + // TODO: Open question - 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? This needs a follow-up discussion and PR. + hasCatalogData := err == nil || resolvedDeprecation != nil + SetDeprecationStatus(ext, installedBundleName, resolvedDeprecation, hasCatalogData) + + if err != nil { + // Note: We don't distinguish between resolution-specific errors and generic errors + setStatusProgressing(ext, err) + setInstalledStatusFromRevisionStates(ext, state.revisionStates) + ensureFailureConditionsWithReason(ext, ocv1.ReasonFailed, err.Error()) + return nil, err + } + + state.resolvedRevisionMetadata = &RevisionMetadata{ + Package: resolvedBundle.Package, + Image: resolvedBundle.Image, + // TODO: Right now, operator-controller only supports registry+v1 bundles and has no concept + // of a "release" field. If/when we add a release field concept or a new bundle format + // we need to re-evaluate use of `AsLegacyRegistryV1Version` so that we avoid propagating + // registry+v1's semver spec violations of treating build metadata as orderable. + BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, resolvedBundleVersion.AsLegacyRegistryV1Version()), } - state.resolvedRevisionMetadata = resolvedRevisionMetadata return nil, nil } } 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 fbc5b4a53..5f02bf7aa 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -1474,12 +1474,13 @@ spec: When Progressing is True and Reason is RollingOut, the ClusterExtension has one or more ClusterExtensionRevisions in active roll out. - When the ClusterExtension is sourced from a catalog, it 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. + 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. + Deprecation conditions are only present when there's something to report - absence means "not deprecated". + - BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet. + - ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found. + - PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found. + - Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). 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 22c7db269..90f34835c 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -1435,12 +1435,13 @@ spec: When Progressing is True and Reason is RollingOut, the ClusterExtension has one or more ClusterExtensionRevisions in active roll out. - When the ClusterExtension is sourced from a catalog, it 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. + 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. + Deprecation conditions are only present when there's something to report - absence means "not deprecated". + - BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet. + - ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found. + - PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found. + - Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). 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 9b8b95c9d..a57b2c3db 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -1077,12 +1077,13 @@ 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, it 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. + 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. + Deprecation conditions are only present when there's something to report - absence means "not deprecated". + - BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet. + - ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found. + - PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found. + - Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). 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 b5166be98..3c6f9359e 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -1038,12 +1038,13 @@ 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, it 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. + 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. + Deprecation conditions are only present when there's something to report - absence means "not deprecated". + - BundleDeprecated is set to True if the installed bundle is marked as deprecated in the catalog, or Unknown if no bundle is installed yet. + - ChannelDeprecated is set to True if any requested channel is marked as deprecated in the catalog, or Unknown if the channel is not found. + - PackageDeprecated is set to True if the requested package is marked as deprecated in the catalog, or Unknown if the package is not found. + - Deprecated is a rollup condition that is present only when at least one deprecation exists (True) or when catalog information is unavailable (Unknown). items: description: Condition contains details for one aspect of the current state of this API Resource.