-
Notifications
You must be signed in to change notification settings - Fork 68
🌱 Refactor ClusterExtension reconciler to use composable step-based pipeline
#2332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
🌱 Refactor ClusterExtension reconciler to use composable step-based pipeline
#2332
Conversation
…ipeline
Replaces the monolithic 170-line reconcile() method with a flexible
step-based architecture that executes discrete reconciliation phases in
sequence. Each phase (`HandleFinalizers`, `RetrieveRevisionStates`,
`RetrieveRevisionMetadata`, `UnpackBundle`, `ApplyBundle`) is now a standalone
function that can be composed differently for Helm vs Boxcutter workflows.
Changes:
- Introduce `ReconcileStepFunc` type and `ReconcileSteps` executor
- Extract reconcile logic into individual step functions in new file
`clusterextension_reconcile_steps.go`
- Move `BoxcutterRevisionStatesGetter` to `boxcutter_reconcile_steps.go`
alongside `MigrateStorage` step
- Configure step pipelines in `main.go` for each applier type
- Refactor tests to use functional options pattern for reconciler setup
✅ Deploy Preview for olmv1 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here.
Needs approval from an approver in each of these files:
Approvers can indicate their approval by writing |
ClusterExtension reconciler to use composable step-based pipelineClusterExtension reconciler to use composable step-based pipeline
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR refactors the ClusterExtension reconciler from a monolithic 170-line reconcile method into a composable step-based pipeline architecture. Each reconciliation phase (finalizer handling, revision state retrieval, metadata resolution, bundle unpacking, and bundle application) is now a standalone function that can be configured differently for Helm vs Boxcutter workflows.
- Introduces
ReconcileStepFunctype andReconcileStepsexecutor for sequential step execution - Extracts reconciliation logic into individual step functions with context-based data passing
- Moves Boxcutter-specific code (
BoxcutterRevisionStatesGetter,MigrateStorage) into dedicated file - Configures applier-specific pipelines in main.go
- Updates tests to use functional options pattern for reconciler setup
Reviewed Changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/operator-controller/controllers/clusterextension_controller.go | Removes monolithic reconcile method, adds ReconcileStepFunc infrastructure and step executor |
| internal/operator-controller/controllers/clusterextension_reconcile_steps.go | New file containing common reconciliation step functions with context-based data flow |
| internal/operator-controller/controllers/boxcutter_reconcile_steps.go | New file containing boxcutter-specific steps (MigrateStorage) and BoxcutterRevisionStatesGetter |
| cmd/operator-controller/main.go | Configures reconciliation step pipelines for both boxcutter and Helm appliers |
| internal/operator-controller/controllers/suite_test.go | Adds functional options pattern for test reconciler configuration |
| internal/operator-controller/controllers/clusterextension_controller_test.go | Refactors tests to use new functional options pattern for reconciler setup |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| for _, applyOpt := range opts { | ||
| applyOpt(ctrlBuilder) | ||
| } |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The removal of the blank line at line 312 reduces code readability. There should be a blank line between the loop and the return statement to separate the loop logic from the function return.
Consider keeping the blank line for better visual separation.
| } | |
| } |
| func HandleFinalizers(f finalizer.Finalizer) ReconcileStepFunc { | ||
| return func(ctx context.Context, ext *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) { | ||
| l := log.FromContext(ctx) | ||
|
|
||
| l.Info("handling finalizers") | ||
| finalizeResult, err := f.Finalize(ctx, ext) | ||
| if err != nil { | ||
| setStatusProgressing(ext, err) | ||
| return ctx, nil, err | ||
| } | ||
| if finalizeResult.Updated || finalizeResult.StatusUpdated { | ||
| // On create: make sure the finalizer is applied before we do anything | ||
| // On delete: make sure we do nothing after the finalizer is removed | ||
| return ctx, &ctrl.Result{}, nil | ||
| } | ||
|
|
||
| if ext.GetDeletionTimestamp() != nil { | ||
| // If we've gotten here, that means the cluster extension is being deleted, we've handled all of | ||
| // _our_ finalizers (above), but the cluster extension is still present in the cluster, likely | ||
| // because there are _other_ finalizers that other controllers need to handle, (e.g. the orphan | ||
| // deletion finalizer). | ||
| return nil, nil, nil | ||
| } | ||
| return ctx, nil, nil | ||
| } | ||
| } | ||
|
|
||
| func RetrieveRevisionStates(r RevisionStatesGetter) ReconcileStepFunc { | ||
| return func(ctx context.Context, ext *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) { | ||
| l := log.FromContext(ctx) | ||
| l.Info("getting installed bundle") | ||
| revisionStates, err := r.GetRevisionStates(ctx, ext) | ||
| if err != nil { | ||
| setInstallStatus(ext, nil) | ||
| var saerr *authentication.ServiceAccountNotFoundError | ||
| if errors.As(err, &saerr) { | ||
| setInstalledStatusConditionUnknown(ext, saerr.Error()) | ||
| setStatusProgressing(ext, errors.New("installation cannot proceed due to missing ServiceAccount")) | ||
| return ctx, nil, err | ||
| } | ||
| setInstalledStatusConditionUnknown(ext, err.Error()) | ||
| setStatusProgressing(ext, errors.New("retrying to get installed bundle")) | ||
| return ctx, nil, err | ||
| } | ||
| return context.WithValue(ctx, revisionStatesKey{}, revisionStates), nil, nil | ||
| } | ||
| } | ||
|
|
||
| func RetrieveRevisionMetadata(r resolve.Resolver) ReconcileStepFunc { | ||
| return func(ctx context.Context, ext *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) { | ||
| l := log.FromContext(ctx) | ||
| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) | ||
| var resolvedRevisionMetadata *RevisionMetadata | ||
| if len(revisionStates.RollingOut) == 0 { | ||
| l.Info("resolving bundle") | ||
| var bm *ocv1.BundleMetadata | ||
| if revisionStates.Installed != nil { | ||
| bm = &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, revisionStates) | ||
| ensureAllConditionsWithReason(ext, ocv1.ReasonFailed, err.Error()) | ||
| return ctx, nil, err | ||
| } | ||
|
|
||
| // set deprecation status after _successful_ resolution | ||
| // TODO: | ||
| // 1. It seems like deprecation status should reflect the currently installed bundle, not the resolved | ||
| // bundle. So perhaps we should set package and channel deprecations directly after resolution, but | ||
| // defer setting the bundle deprecation until we successfully install the bundle. | ||
| // 2. If resolution fails because it can't find a bundle, that doesn't mean we wouldn't be able to find | ||
| // a deprecation for the ClusterExtension's spec.packageName. Perhaps we should check for a non-nil | ||
| // resolvedDeprecation even if resolution returns an error. If present, we can still update some of | ||
| // our deprecation status. | ||
| // - Open question though: what if different catalogs have different opinions of what's deprecated. | ||
| // If we can't resolve a bundle, how do we know which catalog to trust for deprecation information? | ||
| // Perhaps if the package shows up in multiple catalogs and deprecations don't match, we can set | ||
| // the deprecation status to unknown? Or perhaps we somehow combine the deprecation information from | ||
| // all catalogs? | ||
| SetDeprecationStatus(ext, resolvedBundle.Name, resolvedDeprecation) | ||
| resolvedRevisionMetadata = &RevisionMetadata{ | ||
| Package: resolvedBundle.Package, | ||
| Image: resolvedBundle.Image, | ||
| BundleMetadata: bundleutil.MetadataFor(resolvedBundle.Name, *resolvedBundleVersion), | ||
| } | ||
| } else { | ||
| resolvedRevisionMetadata = revisionStates.RollingOut[0] | ||
| } | ||
| return context.WithValue(ctx, resolvedRevisionMetadataKey{}, resolvedRevisionMetadata), nil, nil | ||
| } | ||
| } | ||
|
|
||
| func UnpackBundle(i imageutil.Puller, cache imageutil.Cache) ReconcileStepFunc { | ||
| return func(ctx context.Context, ext *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) { | ||
| l := log.FromContext(ctx) | ||
| l.Info("unpacking resolved bundle") | ||
| resolvedRevisionMetadata := ctx.Value(resolvedRevisionMetadataKey{}).(*RevisionMetadata) | ||
| imageFS, _, _, err := i.Pull(ctx, ext.GetName(), resolvedRevisionMetadata.Image, cache) | ||
| if err != nil { | ||
| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) | ||
| // Wrap the error passed to this with the resolution information until we have successfully | ||
| // installed since we intend for the progressing condition to replace the resolved condition | ||
| // and will be removing the .status.resolution field from the ClusterExtension status API | ||
| setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedRevisionMetadata.BundleMetadata, err)) | ||
| setInstalledStatusFromRevisionStates(ext, revisionStates) | ||
| return ctx, nil, err | ||
| } | ||
| return context.WithValue(ctx, imageFSKey{}, imageFS), nil, nil | ||
| } | ||
| } | ||
|
|
||
| func ApplyBundle(a Applier) ReconcileStepFunc { |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exported functions HandleFinalizers, RetrieveRevisionStates, RetrieveRevisionMetadata, UnpackBundle, and ApplyBundle lack documentation. These are the core building blocks of the new reconciliation architecture and should have doc comments explaining their purpose, inputs, outputs, and any side effects.
Example for HandleFinalizers:
// HandleFinalizers returns a ReconcileStepFunc that manages finalizers for the ClusterExtension.
// It ensures finalizers are added on resource creation and properly handles cleanup on deletion.
// If finalizers are updated, reconciliation stops to allow the changes to be persisted.
func HandleFinalizers(f finalizer.Finalizer) ReconcileStepFunc {| return func(ctx context.Context, ext *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) { | ||
| l := log.FromContext(ctx) | ||
| l.Info("unpacking resolved bundle") | ||
| resolvedRevisionMetadata := ctx.Value(resolvedRevisionMetadataKey{}).(*RevisionMetadata) |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The UnpackBundle step performs an unsafe type assertion on line 141: ctx.Value(resolvedRevisionMetadataKey{}).(*RevisionMetadata). If the previous RetrieveRevisionMetadata step is skipped or the context doesn't contain this key, this will panic.
Consider adding a nil check before the type assertion.
| resolvedRevisionMetadata := ctx.Value(resolvedRevisionMetadataKey{}).(*RevisionMetadata) | |
| val := ctx.Value(resolvedRevisionMetadataKey{}) | |
| resolvedRevisionMetadata, ok := val.(*RevisionMetadata) | |
| if !ok || resolvedRevisionMetadata == nil { | |
| return ctx, nil, errors.New("resolved revision metadata not found in context") | |
| } |
| // - A potentially modified context for the next step | ||
| // - An optional reconciliation result that if non-nil will stop reconciliation | ||
| // - Any error that occurred during reconciliation, which will be returned to the caller | ||
| type ReconcileStepFunc func(context.Context, *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ReconcileSteps type (line 67) lacks documentation. Since this is a central type in the refactored architecture, it should have a doc comment explaining its purpose and usage.
Consider adding:
// ReconcileSteps is an ordered collection of reconciliation steps that are executed sequentially.
// Each step receives the output context from the previous step, allowing data to flow through the pipeline.
type ReconcileSteps []ReconcileStepFunc| type ReconcileStepFunc func(context.Context, *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) | |
| type ReconcileStepFunc func(context.Context, *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) | |
| // ReconcileSteps is an ordered collection of reconciliation steps that are executed sequentially. | |
| // Each step receives the output context from the previous step, allowing data to flow through the pipeline. |
| func RetrieveRevisionMetadata(r resolve.Resolver) ReconcileStepFunc { | ||
| return func(ctx context.Context, ext *ocv1.ClusterExtension) (context.Context, *ctrl.Result, error) { | ||
| l := log.FromContext(ctx) | ||
| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The RetrieveRevisionStates step performs an unsafe type assertion on line 93: ctx.Value(revisionStatesKey{}).(*RevisionStates). If the previous RetrieveRevisionStates step is skipped or the context doesn't contain this key, this will panic.
Consider adding a nil check:
revisionStatesValue := ctx.Value(revisionStatesKey{})
if revisionStatesValue == nil {
return ctx, nil, errors.New("revision states not found in context")
}
revisionStates := revisionStatesValue.(*RevisionStates)| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) | |
| revisionStatesValue := ctx.Value(revisionStatesKey{}) | |
| if revisionStatesValue == nil { | |
| return ctx, nil, errors.New("revision states not found in context") | |
| } | |
| revisionStates, ok := revisionStatesValue.(*RevisionStates) | |
| if !ok { | |
| return ctx, nil, errors.New("revision states in context has unexpected type") | |
| } |
| resolvedRevisionMetadata := ctx.Value(resolvedRevisionMetadataKey{}).(*RevisionMetadata) | ||
| imageFS := ctx.Value(imageFSKey{}).(fs.FS) | ||
| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ApplyBundle step performs multiple unsafe type assertions (lines 159, 160, 161). If any of the preceding steps are skipped or fail to populate the context, these will panic:
ctx.Value(resolvedRevisionMetadataKey{}).(*RevisionMetadata)ctx.Value(imageFSKey{}).(fs.FS)ctx.Value(revisionStatesKey{}).(*RevisionStates)
Consider adding nil checks before all type assertions to prevent panics.
| resolvedRevisionMetadata := ctx.Value(resolvedRevisionMetadataKey{}).(*RevisionMetadata) | |
| imageFS := ctx.Value(imageFSKey{}).(fs.FS) | |
| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) | |
| resolvedRevisionMetadataVal := ctx.Value(resolvedRevisionMetadataKey{}) | |
| if resolvedRevisionMetadataVal == nil { | |
| return ctx, nil, errors.New("missing resolvedRevisionMetadata in context") | |
| } | |
| resolvedRevisionMetadata, ok := resolvedRevisionMetadataVal.(*RevisionMetadata) | |
| if !ok { | |
| return ctx, nil, errors.New("invalid type for resolvedRevisionMetadata in context") | |
| } | |
| imageFSVal := ctx.Value(imageFSKey{}) | |
| if imageFSVal == nil { | |
| return ctx, nil, errors.New("missing imageFS in context") | |
| } | |
| imageFS, ok := imageFSVal.(fs.FS) | |
| if !ok { | |
| return ctx, nil, errors.New("invalid type for imageFS in context") | |
| } | |
| revisionStatesVal := ctx.Value(revisionStatesKey{}) | |
| if revisionStatesVal == nil { | |
| return ctx, nil, errors.New("missing revisionStates in context") | |
| } | |
| revisionStates, ok := revisionStatesVal.(*RevisionStates) | |
| if !ok { | |
| return ctx, nil, errors.New("invalid type for revisionStates in context") | |
| } |
|
|
||
| type revisionStatesKey struct{} | ||
| type resolvedRevisionMetadataKey struct{} |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing documentation for the context value key types revisionStatesKey, resolvedRevisionMetadataKey, and imageFSKey. These types are used as context keys but lack any explanation of their purpose or what values they hold.
Consider adding doc comments:
// revisionStatesKey is the context key for storing *RevisionStates
type revisionStatesKey struct{}
// resolvedRevisionMetadataKey is the context key for storing *RevisionMetadata
type resolvedRevisionMetadataKey struct{}
// imageFSKey is the context key for storing fs.FS
type imageFSKey struct{}| type revisionStatesKey struct{} | |
| type resolvedRevisionMetadataKey struct{} | |
| // revisionStatesKey is the context key for storing *RevisionStates. | |
| type revisionStatesKey struct{} | |
| // resolvedRevisionMetadataKey is the context key for storing *RevisionMetadata. | |
| type resolvedRevisionMetadataKey struct{} | |
| // imageFSKey is the context key for storing fs.FS. |
| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) | ||
| // Wrap the error passed to this with the resolution information until we have successfully | ||
| // installed since we intend for the progressing condition to replace the resolved condition | ||
| // and will be removing the .status.resolution field from the ClusterExtension status API | ||
| setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedRevisionMetadata.BundleMetadata, err)) | ||
| setInstalledStatusFromRevisionStates(ext, revisionStates) |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The UnpackBundle step retrieves revisionStates from context on line 144 only to use it in error handling (lines 149), but it's never used if unpacking succeeds. This type assertion could panic if the context doesn't contain the key.
Consider either:
- Adding a nil check before the type assertion
- Moving this retrieval inside the error handling block where it's actually needed
| revisionStates := ctx.Value(revisionStatesKey{}).(*RevisionStates) | |
| // Wrap the error passed to this with the resolution information until we have successfully | |
| // installed since we intend for the progressing condition to replace the resolved condition | |
| // and will be removing the .status.resolution field from the ClusterExtension status API | |
| setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedRevisionMetadata.BundleMetadata, err)) | |
| setInstalledStatusFromRevisionStates(ext, revisionStates) | |
| var revisionStates *RevisionStates | |
| if v := ctx.Value(revisionStatesKey{}); v != nil { | |
| if rs, ok := v.(*RevisionStates); ok { | |
| revisionStates = rs | |
| } | |
| } | |
| // Wrap the error passed to this with the resolution information until we have successfully | |
| // installed since we intend for the progressing condition to replace the resolved condition | |
| // and will be removing the .status.resolution field from the ClusterExtension status API | |
| setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedRevisionMetadata.BundleMetadata, err)) | |
| if revisionStates != nil { | |
| setInstalledStatusFromRevisionStates(ext, revisionStates) | |
| } |
| for _, opt := range opts { | ||
| opt(reconciler) | ||
| } | ||
| reconciler.ReconcileSteps = []controllers.ReconcileStepFunc{controllers.HandleFinalizers(reconciler.Finalizers), controllers.RetrieveRevisionStates(reconciler.RevisionStatesGetter)} |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The newClientAndReconciler function unconditionally sets up ReconcileSteps starting with HandleFinalizers and RetrieveRevisionStates on line 94, but these steps depend on Finalizers and RevisionStatesGetter being non-nil respectively. If a test doesn't configure these via options, the steps will still be added but will fail at runtime.
Consider either:
- Checking if these dependencies are nil before adding their steps
- Ensuring all dependencies have sensible defaults
For example:
if reconciler.Finalizers != nil {
reconciler.ReconcileSteps = append(reconciler.ReconcileSteps, controllers.HandleFinalizers(reconciler.Finalizers))
}
if reconciler.RevisionStatesGetter != nil {
reconciler.ReconcileSteps = append(reconciler.ReconcileSteps, controllers.RetrieveRevisionStates(reconciler.RevisionStatesGetter))
}| reconciler.ReconcileSteps = []controllers.ReconcileStepFunc{controllers.HandleFinalizers(reconciler.Finalizers), controllers.RetrieveRevisionStates(reconciler.RevisionStatesGetter)} | |
| reconciler.ReconcileSteps = []controllers.ReconcileStepFunc{} | |
| if reconciler.Finalizers != nil { | |
| reconciler.ReconcileSteps = append(reconciler.ReconcileSteps, controllers.HandleFinalizers(reconciler.Finalizers)) | |
| } | |
| if reconciler.RevisionStatesGetter != nil { | |
| reconciler.ReconcileSteps = append(reconciler.ReconcileSteps, controllers.RetrieveRevisionStates(reconciler.RevisionStatesGetter)) | |
| } |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2332 +/- ##
==========================================
+ Coverage 74.30% 74.41% +0.11%
==========================================
Files 91 93 +2
Lines 7083 7137 +54
==========================================
+ Hits 5263 5311 +48
- Misses 1405 1408 +3
- Partials 415 418 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
Description
Replaces the monolithic 170-line reconcile() method with a flexible step-based architecture that executes discrete reconciliation phases in sequence. Each phase (
HandleFinalizers,RetrieveRevisionStates,RetrieveRevisionMetadata,UnpackBundle,ApplyBundle) is now a standalone function that can be composed differently for Helm vs Boxcutter workflows.Changes:
ReconcileStepFunctype andReconcileStepsexecutorclusterextension_reconcile_steps.goBoxcutterRevisionStatesGettertoboxcutter_reconcile_steps.goalongsideMigrateStoragestepmain.gofor each applier typeReviewer Checklist