@@ -18,6 +18,8 @@ package controllers
1818
1919import (
2020 "context"
21+ "crypto/sha256"
22+ "encoding/json"
2123 "errors"
2224 "fmt"
2325
@@ -37,6 +39,51 @@ import (
3739 imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image"
3840)
3941
42+ // resolutionInputs captures the catalog filter fields that affect bundle resolution.
43+ // Changes to any of these fields require re-resolution.
44+ type resolutionInputs struct {
45+ PackageName string `json:"packageName"`
46+ Version string `json:"version"`
47+ Channels []string `json:"channels,omitempty"`
48+ Selector string `json:"selector,omitempty"`
49+ UpgradeConstraintPolicy string `json:"upgradeConstraintPolicy,omitempty"`
50+ }
51+
52+ // calculateResolutionDigest computes a SHA256 hash of the catalog filter inputs
53+ // that affect bundle resolution. This digest enables detection of spec changes that
54+ // require re-resolution versus when a rolling-out revision can be reused.
55+ func calculateResolutionDigest (catalog * ocv1.CatalogFilter ) (string , error ) {
56+ if catalog == nil {
57+ return "" , nil
58+ }
59+
60+ inputs := resolutionInputs {
61+ PackageName : catalog .PackageName ,
62+ Version : catalog .Version ,
63+ Channels : catalog .Channels ,
64+ UpgradeConstraintPolicy : string (catalog .UpgradeConstraintPolicy ),
65+ }
66+
67+ // Convert selector to a canonical string representation
68+ if catalog .Selector != nil {
69+ selectorBytes , err := json .Marshal (catalog .Selector )
70+ if err != nil {
71+ return "" , fmt .Errorf ("marshaling selector: %w" , err )
72+ }
73+ inputs .Selector = string (selectorBytes )
74+ }
75+
76+ // Marshal to JSON for consistent hashing
77+ inputsBytes , err := json .Marshal (inputs )
78+ if err != nil {
79+ return "" , fmt .Errorf ("marshaling resolution inputs: %w" , err )
80+ }
81+
82+ // Compute SHA256 hash and return hex string
83+ hash := sha256 .Sum256 (inputsBytes )
84+ return fmt .Sprintf ("%x" , hash ), nil
85+ }
86+
4087func HandleFinalizers (f finalizer.Finalizer ) ReconcileStepFunc {
4188 return func (ctx context.Context , state * reconcileState , ext * ocv1.ClusterExtension ) (* ctrl.Result , error ) {
4289 l := log .FromContext (ctx )
@@ -140,15 +187,32 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc {
140187 return func (ctx context.Context , state * reconcileState , ext * ocv1.ClusterExtension ) (* ctrl.Result , error ) {
141188 l := log .FromContext (ctx )
142189
143- // If already rolling out, use existing revision and set deprecation to Unknown (no catalog check)
190+ // If already rolling out, check if the latest rolling-out revision matches the current spec.
191+ // If spec has changed (e.g., version, channels, selector, upgradeConstraintPolicy), we need
192+ // to resolve a new bundle instead of reusing the rolling-out revision.
193+ //
194+ // Note: RollingOut slice is sorted oldest→newest, so use the last element (most recent).
195+ // This avoids re-resolving when a newer revision already matches the spec, even if an
196+ // older revision is still stuck rolling out.
144197 if len (state .revisionStates .RollingOut ) > 0 {
145- installedBundleName := ""
146- if state .revisionStates .Installed != nil {
147- installedBundleName = state .revisionStates .Installed .Name
198+ latestRollingOutRevision := state .revisionStates .RollingOut [len (state .revisionStates .RollingOut )- 1 ]
199+
200+ // Calculate digest of current spec's catalog filter
201+ currentDigest , err := calculateResolutionDigest (ext .Spec .Source .Catalog )
202+ if err != nil {
203+ l .Error (err , "failed to calculate resolution digest from spec" )
204+ // On digest calculation error, fall through to re-resolve for safety
205+ } else if currentDigest == latestRollingOutRevision .ResolutionDigest {
206+ // Resolution inputs haven't changed - reuse the rolling-out revision
207+ installedBundleName := ""
208+ if state .revisionStates .Installed != nil {
209+ installedBundleName = state .revisionStates .Installed .Name
210+ }
211+ SetDeprecationStatus (ext , installedBundleName , nil , false )
212+ state .resolvedRevisionMetadata = latestRollingOutRevision
213+ return nil , nil
148214 }
149- SetDeprecationStatus (ext , installedBundleName , nil , false )
150- state .resolvedRevisionMetadata = state .revisionStates .RollingOut [0 ]
151- return nil , nil
215+ // Resolution inputs changed - fall through to resolve new bundle from catalog
152216 }
153217
154218 // Resolve a new bundle from the catalog
@@ -188,9 +252,17 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc {
188252 return handleResolutionError (ctx , c , state , ext , err )
189253 }
190254
255+ // Calculate digest of the resolution inputs for change detection
256+ digest , err := calculateResolutionDigest (ext .Spec .Source .Catalog )
257+ if err != nil {
258+ l .Error (err , "failed to calculate resolution digest, continuing without it" )
259+ digest = ""
260+ }
261+
191262 state .resolvedRevisionMetadata = & RevisionMetadata {
192- Package : resolvedBundle .Package ,
193- Image : resolvedBundle .Image ,
263+ Package : resolvedBundle .Package ,
264+ Image : resolvedBundle .Image ,
265+ ResolutionDigest : digest ,
194266 // TODO: Right now, operator-controller only supports registry+v1 bundles and has no concept
195267 // of a "release" field. If/when we add a release field concept or a new bundle format
196268 // we need to re-evaluate use of `AsLegacyRegistryV1Version` so that we avoid propagating
@@ -409,10 +481,11 @@ func ApplyBundle(a Applier) ReconcileStepFunc {
409481 return func (ctx context.Context , state * reconcileState , ext * ocv1.ClusterExtension ) (* ctrl.Result , error ) {
410482 l := log .FromContext (ctx )
411483 revisionAnnotations := map [string ]string {
412- labels .BundleNameKey : state .resolvedRevisionMetadata .Name ,
413- labels .PackageNameKey : state .resolvedRevisionMetadata .Package ,
414- labels .BundleVersionKey : state .resolvedRevisionMetadata .Version ,
415- labels .BundleReferenceKey : state .resolvedRevisionMetadata .Image ,
484+ labels .BundleNameKey : state .resolvedRevisionMetadata .Name ,
485+ labels .PackageNameKey : state .resolvedRevisionMetadata .Package ,
486+ labels .BundleVersionKey : state .resolvedRevisionMetadata .Version ,
487+ labels .BundleReferenceKey : state .resolvedRevisionMetadata .Image ,
488+ labels .ResolutionDigestKey : state .resolvedRevisionMetadata .ResolutionDigest ,
416489 }
417490 objLbls := map [string ]string {
418491 labels .OwnerKindKey : ocv1 .ClusterExtensionKind ,
0 commit comments