Repository navigation
✨ Adopt ClusterObjectDeployment only when not controlled by another owner #3077
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?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,11 +19,13 @@ import ( | |
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||
| "k8s.io/apimachinery/pkg/runtime" | ||
| "k8s.io/apimachinery/pkg/runtime/schema" | ||
| metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
| "sigs.k8s.io/controller-runtime/pkg/client/apiutil" | ||
| "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" | ||
| "sigs.k8s.io/controller-runtime/pkg/log" | ||
| "sigs.k8s.io/controller-runtime/pkg/reconcile" | ||
| "sigs.k8s.io/yaml" | ||
|
|
||
| helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" | ||
|
|
@@ -468,6 +470,15 @@ func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust | |
| } | ||
| } | ||
|
|
||
| // Refuse to adopt a ClusterObjectDeployment that is already controlled by a | ||
| // different owner. We adopt one that has no controller (stamping our controller | ||
| // ownerReference via the apply below) or one we already control, but taking over a | ||
| // deployment controlled by someone else would fight that owner. A plain terminal | ||
| // error surfaces this as Progressing=False with the Blocked reason. | ||
| if err := bc.checkAdoptable(ctx, ext); err != nil { | ||
| return false, "", err | ||
| } | ||
|
|
||
| // Generate the desired ClusterObjectDeployment and the Secrets its template refs. | ||
| cod, secrets, err := bc.RevisionGenerator.GenerateClusterObjectDeployment(ctx, contentFS, ext, objectLabels, revisionAnnotations) | ||
| if err != nil { | ||
|
|
@@ -525,6 +536,46 @@ func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust | |
| return true, "", nil | ||
| } | ||
|
|
||
| // checkAdoptable verifies that the applier may own the ClusterObjectDeployment named | ||
| // after the ClusterExtension. It is adoptable when it does not yet exist, has no | ||
| // controller ownerReference, is already controlled by this ClusterExtension, or is | ||
| // controlled by a reference that matches this ClusterExtension by group, kind, and name | ||
| // but with a different UID — the latter indicates the ClusterExtension was deleted and | ||
| // recreated (for example a backup/restore), which comes back with a fresh UID, so the | ||
| // deployment is logically still ours. When it is controlled by a genuinely different | ||
| // owner, it returns a terminal error (no specific reason, so the ClusterExtension reports | ||
| // Progressing=False with the Blocked reason) rather than fighting that owner for control. | ||
| func (bc *Boxcutter) checkAdoptable(ctx context.Context, ext *ocv1.ClusterExtension) error { | ||
| existing := &ocv1.ClusterObjectDeployment{} | ||
| switch err := bc.Client.Get(ctx, client.ObjectKey{Name: ext.Name}, existing); { | ||
| case apierrors.IsNotFound(err): | ||
| return nil | ||
| case err != nil: | ||
| return fmt.Errorf("getting ClusterObjectDeployment %q: %w", ext.Name, err) | ||
| } | ||
|
|
||
| controller := metav1.GetControllerOf(existing) | ||
| if controller == nil || controller.UID == ext.UID { | ||
| return nil | ||
| } | ||
|
Comment on lines
+558
to
+560
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the controller owner matches on Group, Kind, Name, I think we should adopt. That would mean that the CE with the expected name was deleted and re-created (which is possible in a backup/restore situation)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call — done.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was doing some tests around this area. It seems like Kubernetes GC will delete an object whose owner ref points to an unknown UID, even if the rest of the owner ref fields match an existing object by name/group/kind. So it may be that the original test using just UID is fine. I'll verify what I think I saw. If true, I think we can use the simpler approach.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed. Objects with unknown UID'd controller refs are GC'd. |
||
|
|
||
| gvk, err := apiutil.GVKForObject(ext, bc.Scheme) | ||
| if err != nil { | ||
| return fmt.Errorf("getting GVK for ClusterExtension %q: %w", ext.Name, err) | ||
| } | ||
| if cgv, parseErr := schema.ParseGroupVersion(controller.APIVersion); parseErr == nil && | ||
| cgv.Group == gvk.Group && | ||
| controller.Kind == gvk.Kind && | ||
| controller.Name == ext.Name { | ||
| return nil | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Replace the stale controller reference when adopting a recreated extension. This branch permits a controller reference with matching group/kind/name but an old UID. If another field manager created or restored the COD, the subsequent server-side apply retains the old reference alongside the new one: I reproduced this against envtest with #3077, #3084, and #3086 applied together. Creating the COD with Please safely replace the stale controller reference as part of adoption and cover the restored/different-field-manager case with an API-server-backed test.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Related: #3077 (comment)? I was planning to revert the group/kind/name comparison back to just a standard UID comparison because kube GC will delete objects that have an ownerRef with an unknown UID anyway. |
||
| } | ||
|
|
||
| return reconcile.TerminalError(fmt.Errorf( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reconcile the extension when the ownership conflict clears. If another extension owns the deployment, this terminal error stops retries. The Boxcutter controller watches deployments through their controller owner reference. If an operator deletes the conflicting deployment, its delete event targets the foreign extension, not the blocked extension. The blocked extension can remain
|
||
| "ClusterObjectDeployment %q is already controlled by %s %q; refusing to adopt it", | ||
| ext.Name, controller.Kind, controller.Name, | ||
| )) | ||
| } | ||
|
|
||
| // runPreflights runs the configured preflight checks for a desired apply. It determines whether | ||
| // this is an install or an upgrade from whether the ClusterObjectDeployment already exists (a | ||
| // direct, controller-owned signal, rather than our own ClusterExtension status which may be stale | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,12 +18,14 @@ import ( | |
| "k8s.io/utils/ptr" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
| "sigs.k8s.io/controller-runtime/pkg/client/fake" | ||
| "sigs.k8s.io/controller-runtime/pkg/reconcile" | ||
|
|
||
| ocv1 "github.com/operator-framework/operator-controller/api/v1" | ||
| ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" | ||
| coscontrollers "github.com/operator-framework/operator-controller/internal/object-controller/controllers" | ||
| "github.com/operator-framework/operator-controller/internal/operator-controller/applier" | ||
| "github.com/operator-framework/operator-controller/internal/shared/labels" | ||
| errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error" | ||
| bundlecsv "github.com/operator-framework/operator-controller/internal/testing/bundle/csv" | ||
| bundlefs "github.com/operator-framework/operator-controller/internal/testing/bundle/fs" | ||
| "github.com/operator-framework/operator-controller/internal/testutil" | ||
|
|
@@ -869,6 +871,124 @@ func TestBoxcutterApply_NilContentUsesExistingCOD(t *testing.T) { | |
| }) | ||
| } | ||
|
|
||
| // TestBoxcutterApply_Adoption verifies the applier only adopts an existing | ||
| // ClusterObjectDeployment when it is not already controlled by a different owner. | ||
| func TestBoxcutterApply_Adoption(t *testing.T) { | ||
| scheme := codTestScheme(t) | ||
| ext := &ocv1.ClusterExtension{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: "test-ext", UID: "test-uid"}, | ||
| } | ||
|
Comment on lines
+878
to
+880
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shared global state seems like it could cause problems, especially since tests are mutating |
||
| objs := []client.Object{ | ||
| &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "test-ns"}}, | ||
| } | ||
|
|
||
| t.Run("adopts an existing deployment with no controller owner reference", func(t *testing.T) { | ||
| existing := &ocv1.ClusterObjectDeployment{ | ||
| ObjectMeta: metav1.ObjectMeta{Name: ext.Name}, | ||
| } | ||
| cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() | ||
| bc := newCODBoxcutter(t, scheme, cl, objs) | ||
|
|
||
| completed, status, err := bc.Apply(t.Context(), dummyBundle, ext, map[string]string{"some": "label"}, map[string]string{}) | ||
| require.NoError(t, err) | ||
| require.True(t, completed) | ||
| require.Empty(t, status) | ||
|
|
||
| cod := &ocv1.ClusterObjectDeployment{} | ||
| require.NoError(t, cl.Get(t.Context(), client.ObjectKey{Name: ext.Name}, cod)) | ||
| owner := metav1.GetControllerOf(cod) | ||
| require.NotNil(t, owner, "expected a controller owner reference after adoption") | ||
| assert.Equal(t, ext.Name, owner.Name) | ||
| assert.Equal(t, ext.UID, owner.UID) | ||
| assert.Equal(t, ocv1.ClusterExtensionKind, owner.Kind) | ||
| }) | ||
|
|
||
| t.Run("proceeds when the existing deployment is already controlled by this extension", func(t *testing.T) { | ||
| existing := &ocv1.ClusterObjectDeployment{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: ext.Name, | ||
| OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(ext, ocv1.GroupVersion.WithKind(ocv1.ClusterExtensionKind))}, | ||
| }, | ||
| } | ||
| cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() | ||
| bc := newCODBoxcutter(t, scheme, cl, objs) | ||
|
|
||
| completed, status, err := bc.Apply(t.Context(), dummyBundle, ext, map[string]string{"some": "label"}, map[string]string{}) | ||
| require.NoError(t, err) | ||
| require.True(t, completed) | ||
| require.Empty(t, status) | ||
| }) | ||
|
|
||
| t.Run("adopts a deployment controlled by a prior incarnation of this extension (same name, different UID)", func(t *testing.T) { | ||
| prior := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: ext.Name, UID: "old-uid"}} | ||
| existing := &ocv1.ClusterObjectDeployment{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: ext.Name, | ||
| OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(prior, ocv1.GroupVersion.WithKind(ocv1.ClusterExtensionKind))}, | ||
| }, | ||
| } | ||
| cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() | ||
| bc := newCODBoxcutter(t, scheme, cl, objs) | ||
|
|
||
| completed, status, err := bc.Apply(t.Context(), dummyBundle, ext, map[string]string{"some": "label"}, map[string]string{}) | ||
| require.NoError(t, err) | ||
| require.True(t, completed) | ||
| require.Empty(t, status) | ||
|
|
||
| t.Log("by re-stamping the current extension as the sole controller owner") | ||
| cod := &ocv1.ClusterObjectDeployment{} | ||
| require.NoError(t, cl.Get(t.Context(), client.ObjectKey{Name: ext.Name}, cod)) | ||
| controllers := 0 | ||
| for _, o := range cod.OwnerReferences { | ||
| if o.Controller != nil && *o.Controller { | ||
| controllers++ | ||
| } | ||
| } | ||
| assert.Equal(t, 1, controllers, "exactly one controller owner reference after adoption") | ||
| owner := metav1.GetControllerOf(cod) | ||
| require.NotNil(t, owner) | ||
| assert.Equal(t, ext.UID, owner.UID, "adoption should re-stamp the current extension's UID") | ||
| }) | ||
|
|
||
| t.Run("refuses to adopt a deployment controlled by a different owner", func(t *testing.T) { | ||
| foreign := &ocv1.ClusterExtension{ObjectMeta: metav1.ObjectMeta{Name: "other-ext", UID: "other-uid"}} | ||
| existing := &ocv1.ClusterObjectDeployment{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: ext.Name, | ||
| OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(foreign, ocv1.GroupVersion.WithKind(ocv1.ClusterExtensionKind))}, | ||
| }, | ||
| } | ||
| cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() | ||
| bc := newCODBoxcutter(t, scheme, cl, objs) | ||
|
|
||
| completed, status, err := bc.Apply(t.Context(), dummyBundle, ext, map[string]string{"some": "label"}, map[string]string{}) | ||
| require.Error(t, err) | ||
| assert.False(t, completed) | ||
| assert.Empty(t, status) | ||
|
|
||
| t.Log("by surfacing a terminal error so Progressing becomes False") | ||
| require.ErrorIs(t, err, reconcile.TerminalError(nil), "error must be terminal") | ||
| t.Log("by carrying no specific reason so it falls back to the Blocked Progressing reason") | ||
| _, ok := errorutil.ExtractTerminalReason(err) | ||
| assert.False(t, ok, "terminal error should fall back to the Blocked reason") | ||
| t.Log("by naming the deployment and the conflicting owner in the message") | ||
| assert.Contains(t, err.Error(), ext.Name) | ||
| assert.Contains(t, err.Error(), "other-ext") | ||
|
|
||
| t.Log("by leaving the foreign-owned deployment untouched") | ||
| cod := &ocv1.ClusterObjectDeployment{} | ||
| require.NoError(t, cl.Get(t.Context(), client.ObjectKey{Name: ext.Name}, cod)) | ||
| owner := metav1.GetControllerOf(cod) | ||
| require.NotNil(t, owner) | ||
| assert.Equal(t, "other-ext", owner.Name) | ||
|
|
||
| t.Log("by not creating any packed Secrets for the refused deployment") | ||
| secretList := &corev1.SecretList{} | ||
| require.NoError(t, cl.List(t.Context(), secretList, client.InNamespace("olmv1-system"))) | ||
| assert.Empty(t, secretList.Items) | ||
| }) | ||
| } | ||
|
|
||
| // TestBoxcutterApply_Preflights verifies which preflight hook runs for each rollout state | ||
| // and that a preflight failure propagates out of Apply. Each row is a sequence of applies | ||
| // against the same Boxcutter: a step renders its own objects, so identical content between | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -429,6 +429,13 @@ func collectDeprecationMessages(entries []declcfg.DeprecationEntry) []string { | |
| // for each ClusterExtension. The ClusterExtension observes the deployment's rolled-up rollout status, | ||
| // and the deployment is controller-owned by the ClusterExtension, so an owner watch re-reconciles the | ||
| // ClusterExtension whenever its deployment's status changes. | ||
| // | ||
| // TODO: the owner watch only enqueues the ClusterExtension that controls a deployment. When the applier | ||
| // refuses to adopt a deployment controlled by a different owner (see Boxcutter.checkAdoptable), the | ||
| // blocked ClusterExtension does not own that deployment, so deleting the conflicting deployment does not | ||
| // enqueue it; it recovers only on its next spec/catalog change or the periodic resync. Add a name-keyed | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FWIW, I think we've effectively disabled periodic resync by using the ResourceVersionChanged predicate. Was that an intentional choice? If not, perhaps something to reconsider.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, maybe instead of watching/caching all CODs in the CE informer cache so that we can do this mapping, we can just use |
||
| // watch that enqueues the same-named ClusterExtension on any ClusterObjectDeployment change so a blocked | ||
| // extension recovers promptly once the conflict clears. | ||
|
perdasilva marked this conversation as resolved.
Resolved
|
||
| func (r *ClusterExtensionReconciler) SetupWithManagerForBoxcutter(mgr ctrl.Manager) (crcontroller.Controller, error) { | ||
| return r.newControllerBuilder(mgr). | ||
| Owns(&ocv1.ClusterObjectDeployment{}). | ||
|
|
||
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.
I'm a bit concerned about a resolver / adoption interaction.
Say there is a standalone, unowned COD out there with a Ready COS. Life is good. Now someone creates a CE that just happens to have the same name as the COD.
With this code, we'll still forcibly take over that COD even though there's no affirmative signal from the COD owner that that is the desire. Perhaps we need the COD to have the owner-name label added to provide that signal to our adoption logic. Right now our logic is essentially
IfNoController, but I think it should bePrevent, which matches the default that we use for the COS-managed objects.So I think that's problem (1).
Problem (2): suppose folks agree and fix the above problem. Now we've got a COD/COS tree that we just adopted. That COD/COS tree seems pretty unlikely to have bundle name/version labels that allow our resolver to identify what it is. What should our resolver do? Should it:
successorsfiltering. This is the same asupdateConstraintPolicy: SelfCertified