Skip to content

Commit a6d0678

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

File tree

4 files changed

+376
-93
lines changed

4 files changed

+376
-93
lines changed

internal/operator-controller/controllers/clusterextension_admission_test.go

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

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

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

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

internal/operator-controller/controllers/clusterextension_controller.go

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

28+
bsemver "github.com/blang/semver/v4"
2829
"github.com/go-logr/logr"
2930
"helm.sh/helm/v3/pkg/release"
3031
"helm.sh/helm/v3/pkg/storage/driver"
@@ -229,37 +230,53 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
229230
return ctrl.Result{}, err
230231
}
231232

233+
// Defer deprecation updates until the end of reconcile. We only want to talk about
234+
// catalog deprecation data after we know which bundle is actually installed.
235+
// This always runs (success or failure) so install/validation errors never leak into
236+
// the deprecation conditions.
237+
//
238+
// What the deferred update reports:
239+
// * Package deprecation: true if the package is marked deprecated in the catalog
240+
// * Channel deprecation: true if any requested channel is deprecated in the catalog
241+
// * Bundle deprecation: reflects the INSTALLED bundle
242+
// - no bundle installed -> status Unknown, reason Absent
243+
// - installed bundle deprecated -> status True, reason Deprecated
244+
// - installed bundle not deprecated -> status False, reason Deprecated
245+
//
246+
// If a bundle resolves but fails to install, package/channel results still show the
247+
// catalog data, and the bundle condition stays Unknown because nothing is installed.
248+
var resolvedDeprecation *declcfg.Deprecation
249+
defer func() {
250+
bundleName := ""
251+
if revisionStates != nil && revisionStates.Installed != nil {
252+
bundleName = revisionStates.Installed.Name
253+
}
254+
SetDeprecationStatus(ext, bundleName, resolvedDeprecation)
255+
}()
256+
232257
var resolvedRevisionMetadata *RevisionMetadata
233258
if len(revisionStates.RollingOut) == 0 {
234259
l.Info("resolving bundle")
235260
var bm *ocv1.BundleMetadata
236261
if revisionStates.Installed != nil {
237262
bm = &revisionStates.Installed.BundleMetadata
238263
}
239-
resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err := r.Resolver.Resolve(ctx, ext, bm)
264+
var resolvedBundle *declcfg.Bundle
265+
var resolvedBundleVersion *bsemver.Version
266+
resolvedBundle, resolvedBundleVersion, resolvedDeprecation, err = r.Resolver.Resolve(ctx, ext, bm)
267+
// Keep any deprecation data the resolver returned. The deferred update will use it
268+
// even if installation later fails or never begins.
240269
if err != nil {
241270
// Note: We don't distinguish between resolution-specific errors and generic errors
242271
setStatusProgressing(ext, err)
243272
setInstalledStatusFromRevisionStates(ext, revisionStates)
273+
// ensureAllConditionsWithReason writes the failure message to every condition.
274+
// The deferred SetDeprecationStatus call overwrites the deprecation conditions right
275+
// away so they only reflect catalog data. This also supports the case where resolution
276+
// returned deprecation info even though it could not find an installable bundle.
244277
ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error())
245278
return ctrl.Result{}, err
246279
}
247-
248-
// set deprecation status after _successful_ resolution
249-
// TODO:
250-
// 1. It seems like deprecation status should reflect the currently installed bundle, not the resolved
251-
// bundle. So perhaps we should set package and channel deprecations directly after resolution, but
252-
// defer setting the bundle deprecation until we successfully install the bundle.
253-
// 2. If resolution fails because it can't find a bundle, that doesn't mean we wouldn't be able to find
254-
// a deprecation for the ClusterExtension's spec.packageName. Perhaps we should check for a non-nil
255-
// resolvedDeprecation even if resolution returns an error. If present, we can still update some of
256-
// our deprecation status.
257-
// - Open question though: what if different catalogs have different opinions of what's deprecated.
258-
// If we can't resolve a bundle, how do we know which catalog to trust for deprecation information?
259-
// Perhaps if the package shows up in multiple catalogs and deprecations don't match, we can set
260-
// the deprecation status to unknown? Or perhaps we somehow combine the deprecation information from
261-
// all catalogs?
262-
SetDeprecationStatus(ext, resolvedBundle.Name, resolvedDeprecation)
263280
resolvedRevisionMetadata = &RevisionMetadata{
264281
Package: resolvedBundle.Package,
265282
Image: resolvedBundle.Image,
@@ -326,83 +343,143 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl
326343
return ctrl.Result{}, nil
327344
}
328345

329-
// SetDeprecationStatus will set the appropriate deprecation statuses for a ClusterExtension
330-
// based on the provided bundle
346+
// DeprecationInfo captures the deprecation data needed to update condition status.
347+
type DeprecationInfo struct {
348+
PackageEntries []declcfg.DeprecationEntry
349+
ChannelEntries []declcfg.DeprecationEntry
350+
BundleEntries []declcfg.DeprecationEntry
351+
BundleStatus metav1.ConditionStatus
352+
}
353+
354+
// SetDeprecationStatus updates the ClusterExtension deprecation conditions based on the
355+
// provided deprecation information and the currently installed bundle name. When bundleName
356+
// is empty, no bundle is considered installed and the bundle deprecation condition is
357+
// reported as Unknown with reason Absent.
358+
//
359+
// This keeps the deprecation conditions focused on catalog information:
360+
// - PackageDeprecated: true if the catalog marks the package deprecated
361+
// - ChannelDeprecated: true if any requested channel is marked deprecated
362+
// - BundleDeprecated: reflects the installed bundle (Unknown/Absent when nothing installed)
363+
// - Deprecated (rollup): true if any of the above signals a deprecation
364+
//
365+
// Install or validation errors never appear here because they belong on the
366+
// Progressing/Installed conditions instead. Callers should invoke this after reconcile
367+
// finishes (for example via a defer) so catalog data replaces any transient error messages.
331368
func SetDeprecationStatus(ext *ocv1.ClusterExtension, bundleName string, deprecation *declcfg.Deprecation) {
332-
deprecations := map[string][]declcfg.DeprecationEntry{}
333-
channelSet := sets.New[string]()
369+
info := buildDeprecationInfo(ext, bundleName, deprecation)
370+
setDeprecatedConditions(ext, info)
371+
}
372+
373+
func buildDeprecationInfo(ext *ocv1.ClusterExtension, bundleName string, deprecation *declcfg.Deprecation) DeprecationInfo {
374+
info := DeprecationInfo{BundleStatus: metav1.ConditionUnknown}
375+
var channelSet sets.Set[string]
334376
if ext.Spec.Source.Catalog != nil {
335-
for _, channel := range ext.Spec.Source.Catalog.Channels {
336-
channelSet.Insert(channel)
337-
}
377+
channelSet = sets.New(ext.Spec.Source.Catalog.Channels...)
378+
} else {
379+
channelSet = sets.New[string]()
338380
}
381+
339382
if deprecation != nil {
340383
for _, entry := range deprecation.Entries {
341384
switch entry.Reference.Schema {
342385
case declcfg.SchemaPackage:
343-
deprecations[ocv1.TypePackageDeprecated] = []declcfg.DeprecationEntry{entry}
386+
info.PackageEntries = append(info.PackageEntries, entry)
344387
case declcfg.SchemaChannel:
345388
if channelSet.Has(entry.Reference.Name) {
346-
deprecations[ocv1.TypeChannelDeprecated] = append(deprecations[ocv1.TypeChannelDeprecated], entry)
389+
info.ChannelEntries = append(info.ChannelEntries, entry)
347390
}
348391
case declcfg.SchemaBundle:
349-
if bundleName != entry.Reference.Name {
392+
if bundleName == "" || entry.Reference.Name != bundleName {
350393
continue
351394
}
352-
deprecations[ocv1.TypeBundleDeprecated] = []declcfg.DeprecationEntry{entry}
395+
info.BundleEntries = append(info.BundleEntries, entry)
353396
}
354397
}
355398
}
356399

357-
// first get ordered deprecation messages that we'll join in the Deprecated condition message
358-
var deprecationMessages []string
359-
for _, conditionType := range []string{
360-
ocv1.TypePackageDeprecated,
361-
ocv1.TypeChannelDeprecated,
362-
ocv1.TypeBundleDeprecated,
363-
} {
364-
if entries, ok := deprecations[conditionType]; ok {
365-
for _, entry := range entries {
366-
deprecationMessages = append(deprecationMessages, entry.Message)
367-
}
368-
}
400+
if bundleName == "" {
401+
info.BundleStatus = metav1.ConditionUnknown
402+
} else if len(info.BundleEntries) > 0 {
403+
info.BundleStatus = metav1.ConditionTrue
404+
} else {
405+
info.BundleStatus = metav1.ConditionFalse
406+
}
407+
408+
return info
409+
}
410+
411+
func setDeprecatedConditions(ext *ocv1.ClusterExtension, info DeprecationInfo) {
412+
packageMessages := collectDeprecationMessages(info.PackageEntries)
413+
channelMessages := collectDeprecationMessages(info.ChannelEntries)
414+
bundleMessages := collectDeprecationMessages(info.BundleEntries)
415+
416+
messages := make([]string, 0, len(packageMessages)+len(channelMessages)+len(bundleMessages))
417+
messages = append(messages, packageMessages...)
418+
messages = append(messages, channelMessages...)
419+
if info.BundleStatus == metav1.ConditionTrue {
420+
messages = append(messages, bundleMessages...)
369421
}
370422

371-
// next, set the Deprecated condition
372-
status, reason, message := metav1.ConditionFalse, ocv1.ReasonDeprecated, ""
373-
if len(deprecationMessages) > 0 {
374-
status, reason, message = metav1.ConditionTrue, ocv1.ReasonDeprecated, strings.Join(deprecationMessages, ";")
423+
status := metav1.ConditionFalse
424+
if len(messages) > 0 {
425+
status = metav1.ConditionTrue
375426
}
427+
376428
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
377429
Type: ocv1.TypeDeprecated,
378-
Reason: reason,
379430
Status: status,
380-
Message: message,
381-
ObservedGeneration: ext.Generation,
431+
Reason: ocv1.ReasonDeprecated,
432+
Message: strings.Join(messages, "\n"),
433+
ObservedGeneration: ext.GetGeneration(),
382434
})
383435

384-
// finally, set the individual deprecation conditions for package, channel, and bundle
385-
for _, conditionType := range []string{
386-
ocv1.TypePackageDeprecated,
387-
ocv1.TypeChannelDeprecated,
388-
ocv1.TypeBundleDeprecated,
389-
} {
390-
entries, ok := deprecations[conditionType]
391-
status, reason, message := metav1.ConditionFalse, ocv1.ReasonDeprecated, ""
392-
if ok {
393-
status, reason = metav1.ConditionTrue, ocv1.ReasonDeprecated
394-
for _, entry := range entries {
395-
message = fmt.Sprintf("%s\n%s", message, entry.Message)
396-
}
436+
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
437+
Type: ocv1.TypePackageDeprecated,
438+
Status: conditionStatus(len(packageMessages) > 0),
439+
Reason: ocv1.ReasonDeprecated,
440+
Message: strings.Join(packageMessages, "\n"),
441+
ObservedGeneration: ext.GetGeneration(),
442+
})
443+
444+
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
445+
Type: ocv1.TypeChannelDeprecated,
446+
Status: conditionStatus(len(channelMessages) > 0),
447+
Reason: ocv1.ReasonDeprecated,
448+
Message: strings.Join(channelMessages, "\n"),
449+
ObservedGeneration: ext.GetGeneration(),
450+
})
451+
452+
bundleReason := ocv1.ReasonDeprecated
453+
bundleMessage := strings.Join(bundleMessages, "\n")
454+
if info.BundleStatus == metav1.ConditionUnknown {
455+
bundleReason = ocv1.ReasonAbsent
456+
bundleMessage = ""
457+
}
458+
459+
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
460+
Type: ocv1.TypeBundleDeprecated,
461+
Status: info.BundleStatus,
462+
Reason: bundleReason,
463+
Message: bundleMessage,
464+
ObservedGeneration: ext.GetGeneration(),
465+
})
466+
}
467+
468+
func collectDeprecationMessages(entries []declcfg.DeprecationEntry) []string {
469+
messages := make([]string, 0, len(entries))
470+
for _, entry := range entries {
471+
if entry.Message != "" {
472+
messages = append(messages, entry.Message)
397473
}
398-
SetStatusCondition(&ext.Status.Conditions, metav1.Condition{
399-
Type: conditionType,
400-
Reason: reason,
401-
Status: status,
402-
Message: message,
403-
ObservedGeneration: ext.Generation,
404-
})
405474
}
475+
return messages
476+
}
477+
478+
func conditionStatus(ok bool) metav1.ConditionStatus {
479+
if ok {
480+
return metav1.ConditionTrue
481+
}
482+
return metav1.ConditionFalse
406483
}
407484

408485
type ControllerBuilderOption func(builder *ctrl.Builder)

0 commit comments

Comments
 (0)