@@ -18,8 +18,12 @@ package controllers
1818
1919import (
2020 "context"
21+ "crypto/sha256"
22+ "encoding/base64"
23+ "encoding/json"
2124 "errors"
2225 "fmt"
26+ "slices"
2327
2428 apierrors "k8s.io/apimachinery/pkg/api/errors"
2529 apimeta "k8s.io/apimachinery/pkg/api/meta"
@@ -37,6 +41,58 @@ import (
3741 imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image"
3842)
3943
44+ // resolutionInputs captures the catalog filter fields that affect bundle resolution.
45+ // Changes to any of these fields require re-resolution.
46+ type resolutionInputs struct {
47+ PackageName string `json:"packageName"`
48+ Version string `json:"version"`
49+ Channels []string `json:"channels,omitempty"`
50+ Selector string `json:"selector,omitempty"`
51+ UpgradeConstraintPolicy string `json:"upgradeConstraintPolicy,omitempty"`
52+ }
53+
54+ // calculateResolutionDigest computes a SHA256 hash of the catalog filter inputs
55+ // that affect bundle resolution. This digest enables detection of spec changes that
56+ // require re-resolution versus when a rolling-out revision can be reused.
57+ //
58+ // The digest is base64 URL-safe encoded (43 characters) to fit within Kubernetes
59+ // label value limits (63 characters max) when stored in Helm release metadata.
60+ func calculateResolutionDigest (catalog * ocv1.CatalogFilter ) (string , error ) {
61+ if catalog == nil {
62+ return "" , nil
63+ }
64+
65+ // Sort channels for deterministic hashing
66+ channels := slices .Clone (catalog .Channels )
67+ slices .Sort (channels )
68+
69+ inputs := resolutionInputs {
70+ PackageName : catalog .PackageName ,
71+ Version : catalog .Version ,
72+ Channels : channels ,
73+ UpgradeConstraintPolicy : string (catalog .UpgradeConstraintPolicy ),
74+ }
75+
76+ // Convert selector to canonical string representation for deterministic hashing
77+ if catalog .Selector != nil {
78+ selector , err := metav1 .LabelSelectorAsSelector (catalog .Selector )
79+ if err != nil {
80+ return "" , fmt .Errorf ("converting selector: %w" , err )
81+ }
82+ inputs .Selector = selector .String ()
83+ }
84+
85+ // Marshal to JSON for consistent hashing
86+ inputsBytes , err := json .Marshal (inputs )
87+ if err != nil {
88+ return "" , fmt .Errorf ("marshaling resolution inputs: %w" , err )
89+ }
90+
91+ // Compute SHA256 hash and encode as base64 URL-safe (43 chars, fits in label value limit)
92+ hash := sha256 .Sum256 (inputsBytes )
93+ return base64 .RawURLEncoding .EncodeToString (hash [:]), nil
94+ }
95+
4096func HandleFinalizers (f finalizer.Finalizer ) ReconcileStepFunc {
4197 return func (ctx context.Context , state * reconcileState , ext * ocv1.ClusterExtension ) (* ctrl.Result , error ) {
4298 l := log .FromContext (ctx )
@@ -140,15 +196,32 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc {
140196 return func (ctx context.Context , state * reconcileState , ext * ocv1.ClusterExtension ) (* ctrl.Result , error ) {
141197 l := log .FromContext (ctx )
142198
143- // If already rolling out, use existing revision and set deprecation to Unknown (no catalog check)
199+ // If already rolling out, check if the latest rolling-out revision matches the current spec.
200+ // If spec has changed (e.g., version, channels, selector, upgradeConstraintPolicy), we need
201+ // to resolve a new bundle instead of reusing the rolling-out revision.
202+ //
203+ // Note: RollingOut slice is sorted oldest→newest, so use the last element (most recent).
204+ // This avoids re-resolving when a newer revision already matches the spec, even if an
205+ // older revision is still stuck rolling out.
144206 if len (state .revisionStates .RollingOut ) > 0 {
145- installedBundleName := ""
146- if state .revisionStates .Installed != nil {
147- installedBundleName = state .revisionStates .Installed .Name
207+ latestRollingOutRevision := state .revisionStates .RollingOut [len (state .revisionStates .RollingOut )- 1 ]
208+
209+ // Calculate digest of current spec's catalog filter
210+ currentDigest , err := calculateResolutionDigest (ext .Spec .Source .Catalog )
211+ if err != nil {
212+ l .Error (err , "failed to calculate resolution digest from spec" )
213+ // On digest calculation error, fall through to re-resolve for safety
214+ } else if currentDigest == latestRollingOutRevision .ResolutionDigest {
215+ // Resolution inputs haven't changed - reuse the rolling-out revision
216+ installedBundleName := ""
217+ if state .revisionStates .Installed != nil {
218+ installedBundleName = state .revisionStates .Installed .Name
219+ }
220+ SetDeprecationStatus (ext , installedBundleName , nil , false )
221+ state .resolvedRevisionMetadata = latestRollingOutRevision
222+ return nil , nil
148223 }
149- SetDeprecationStatus (ext , installedBundleName , nil , false )
150- state .resolvedRevisionMetadata = state .revisionStates .RollingOut [0 ]
151- return nil , nil
224+ // Resolution inputs changed - fall through to resolve new bundle from catalog
152225 }
153226
154227 // Resolve a new bundle from the catalog
@@ -188,9 +261,17 @@ func ResolveBundle(r resolve.Resolver, c client.Client) ReconcileStepFunc {
188261 return handleResolutionError (ctx , c , state , ext , err )
189262 }
190263
264+ // Calculate digest of the resolution inputs for change detection
265+ digest , err := calculateResolutionDigest (ext .Spec .Source .Catalog )
266+ if err != nil {
267+ l .Error (err , "failed to calculate resolution digest, continuing without it" )
268+ digest = ""
269+ }
270+
191271 state .resolvedRevisionMetadata = & RevisionMetadata {
192- Package : resolvedBundle .Package ,
193- Image : resolvedBundle .Image ,
272+ Package : resolvedBundle .Package ,
273+ Image : resolvedBundle .Image ,
274+ ResolutionDigest : digest ,
194275 // TODO: Right now, operator-controller only supports registry+v1 bundles and has no concept
195276 // of a "release" field. If/when we add a release field concept or a new bundle format
196277 // we need to re-evaluate use of `AsLegacyRegistryV1Version` so that we avoid propagating
@@ -409,10 +490,11 @@ func ApplyBundle(a Applier) ReconcileStepFunc {
409490 return func (ctx context.Context , state * reconcileState , ext * ocv1.ClusterExtension ) (* ctrl.Result , error ) {
410491 l := log .FromContext (ctx )
411492 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 ,
493+ labels .BundleNameKey : state .resolvedRevisionMetadata .Name ,
494+ labels .PackageNameKey : state .resolvedRevisionMetadata .Package ,
495+ labels .BundleVersionKey : state .resolvedRevisionMetadata .Version ,
496+ labels .BundleReferenceKey : state .resolvedRevisionMetadata .Image ,
497+ labels .ResolutionDigestKey : state .resolvedRevisionMetadata .ResolutionDigest ,
416498 }
417499 objLbls := map [string ]string {
418500 labels .OwnerKindKey : ocv1 .ClusterExtensionKind ,
0 commit comments