From f96741bdfbc11667cfc50eb68d5b732d0fcc5da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:27:45 +0200 Subject: [PATCH 1/5] feat(component): add OrphanWhen resource option and resolution --- pkg/component/resource_options.go | 39 ++++++++++++++++++++++++ pkg/component/resource_options_test.go | 41 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/pkg/component/resource_options.go b/pkg/component/resource_options.go index 36608d5d..7eb839e3 100644 --- a/pkg/component/resource_options.go +++ b/pkg/component/resource_options.go @@ -27,6 +27,7 @@ type ResourceOption func(*resourceConfig) type resourceConfig struct { readOnly bool deleteConditions []bool + orphanConditions []bool gate feature.Gate participationMode ParticipationMode blockOnAbsence bool @@ -41,6 +42,10 @@ type resourceConfig struct { type resourceOptions struct { // Delete reports that the resource is marked for deletion during reconciliation. Delete bool + // Orphan reports that the resource is released during reconciliation: the + // component removes its controller owner reference and stops managing it, + // leaving the object in the cluster. + Orphan bool // ReadOnly reports that the resource is read-only. ReadOnly bool // ParticipationMode describes how the resource participates in the component @@ -87,6 +92,19 @@ func DeleteWhen(condition bool) ResourceOption { return func(c *resourceConfig) { c.deleteConditions = append(c.deleteConditions, condition) } } +// OrphanWhen releases the resource from the component when condition is true: the +// component stops managing it and removes the controller owner reference it set, +// leaving the object in the cluster rather than deleting it. Use it to migrate a +// resource to a new owner without deleting it. +// +// Calls are additive with OR semantics: the resource is orphaned if any supplied +// condition is true. OrphanWhen is mutually exclusive with Delete, DeleteWhen, +// GatedBy, and ReadOnly; combining any of them is a configuration error returned +// by Build. +func OrphanWhen(condition bool) ResourceOption { + return func(c *resourceConfig) { c.orphanConditions = append(c.orphanConditions, condition) } +} + // GatedBy conditionally renders an owned resource based on a feature.Gate: it is // the option to reach for when a resource the component owns should exist for // some feature states and be removed for others. When the gate is disabled the @@ -176,6 +194,18 @@ func (c *resourceConfig) resolve() (resourceOptions, error) { ) } + if len(c.orphanConditions) > 0 { + if len(c.deleteConditions) > 0 { + return resourceOptions{}, errors.New("resource option OrphanWhen is mutually exclusive with Delete and DeleteWhen") + } + if c.gate != nil { + return resourceOptions{}, errors.New("resource option OrphanWhen is mutually exclusive with GatedBy") + } + if c.readOnly { + return resourceOptions{}, errors.New("resource option OrphanWhen is mutually exclusive with ReadOnly") + } + } + shouldDelete := false if c.gate != nil { enabled, err := c.gate.Enabled() @@ -195,6 +225,14 @@ func (c *resourceConfig) resolve() (resourceOptions, error) { } } + shouldOrphan := false + for _, cond := range c.orphanConditions { + if cond { + shouldOrphan = true + break + } + } + mode := c.participationMode if mode == "" { mode = ParticipationModeRequired @@ -204,6 +242,7 @@ func (c *resourceConfig) resolve() (resourceOptions, error) { // any read-only resource that carries a deletion trigger. return resourceOptions{ Delete: shouldDelete, + Orphan: shouldOrphan, ReadOnly: c.readOnly, ParticipationMode: mode, SuppressGraceInconsistencyWarning: c.suppressGraceInconsistencyWarning, diff --git a/pkg/component/resource_options_test.go b/pkg/component/resource_options_test.go index 99edaf39..3aae7182 100644 --- a/pkg/component/resource_options_test.go +++ b/pkg/component/resource_options_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/sourcehawk/operator-component-framework/pkg/feature" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -143,6 +144,46 @@ func TestResolveResourceOptions(t *testing.T) { } } +func TestResolve_OrphanWhen(t *testing.T) { + t.Run("orphan when false is managed normally", func(t *testing.T) { + opts, err := resolveResourceOptions([]ResourceOption{OrphanWhen(false)}) + require.NoError(t, err) + assert.False(t, opts.Orphan) + assert.False(t, opts.Delete) + }) + t.Run("orphan when true sets Orphan", func(t *testing.T) { + opts, err := resolveResourceOptions([]ResourceOption{OrphanWhen(true)}) + require.NoError(t, err) + assert.True(t, opts.Orphan) + assert.False(t, opts.Delete) + }) + t.Run("orphan conditions are additive (any true)", func(t *testing.T) { + opts, err := resolveResourceOptions([]ResourceOption{OrphanWhen(false), OrphanWhen(true)}) + require.NoError(t, err) + assert.True(t, opts.Orphan) + }) +} + +func TestResolve_OrphanWhen_Exclusivity(t *testing.T) { + cases := []struct { + name string + opts []ResourceOption + want string + }{ + {"orphan + delete", []ResourceOption{OrphanWhen(true), Delete()}, "OrphanWhen is mutually exclusive with Delete"}, + {"orphan + deleteWhen", []ResourceOption{OrphanWhen(true), DeleteWhen(true)}, "OrphanWhen is mutually exclusive with Delete"}, + {"orphan + gatedBy", []ResourceOption{OrphanWhen(true), GatedBy(feature.NewBooleanGate(true))}, "OrphanWhen is mutually exclusive with GatedBy"}, + {"orphan + readonly", []ResourceOption{OrphanWhen(true), ReadOnly()}, "OrphanWhen is mutually exclusive with ReadOnly"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := resolveResourceOptions(tc.opts) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + func TestResolveResourceOptions_ValidationErrors(t *testing.T) { t.Parallel() tests := []struct { From a0970c6bcbe39e7327370bc142c488c687b748ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:29:29 +0200 Subject: [PATCH 2/5] feat(component): partition OrphanWhen resources into orphanResources --- pkg/component/builder.go | 7 +++- pkg/component/component.go | 1 + pkg/component/orphan_integration_test.go | 49 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 pkg/component/orphan_integration_test.go diff --git a/pkg/component/builder.go b/pkg/component/builder.go index aa9bf776..164269f3 100644 --- a/pkg/component/builder.go +++ b/pkg/component/builder.go @@ -153,9 +153,12 @@ func (b *Builder) WithResource(resource Resource, opts ...ResourceOption) *Build b.component.resourceLookup[identity] = resource - if options.Delete { + switch { + case options.Orphan: + b.component.orphanResources = append(b.component.orphanResources, resource) + case options.Delete: b.component.deleteResources = append(b.component.deleteResources, resource) - } else { + default: b.component.reconcileResources = append(b.component.reconcileResources, reconcileEntry{ Resource: resource, Options: options, diff --git a/pkg/component/component.go b/pkg/component/component.go index d2083817..a3703926 100644 --- a/pkg/component/component.go +++ b/pkg/component/component.go @@ -99,6 +99,7 @@ type Component struct { // Each entry pairs the resource with its full options. reconcileResources []reconcileEntry deleteResources []Resource + orphanResources []Resource resourceLookup map[string]Resource gracePeriod time.Duration diff --git a/pkg/component/orphan_integration_test.go b/pkg/component/orphan_integration_test.go new file mode 100644 index 00000000..5b161270 --- /dev/null +++ b/pkg/component/orphan_integration_test.go @@ -0,0 +1,49 @@ +package component + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// orphanTestResource returns a MockResource stubbed to act as a ConfigMap named +// name in test-namespace. Object and Identity are the only methods the orphan +// path invokes, so only those are registered. +func orphanTestResource(name string) *MockResource { + res := &MockResource{} + res.On("Object").Return(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test-namespace"}, + }, nil) + res.On("Identity").Return("v1/ConfigMap/" + name) + return res +} + +func TestBuild_OrphanWhenPartition(t *testing.T) { + t.Run("OrphanWhen(true) partitions into orphanResources", func(t *testing.T) { + c, err := NewComponentBuilder(). + WithName("orphan-comp"). + WithConditionType("Ready"). + WithResource(orphanTestResource("orphaned-cm"), OrphanWhen(true)). + Build() + require.NoError(t, err) + + assert.Len(t, c.orphanResources, 1) + assert.Empty(t, c.deleteResources) + assert.Empty(t, c.allManagedResources()) + }) + + t.Run("OrphanWhen(false) is managed normally", func(t *testing.T) { + managed, err := NewComponentBuilder(). + WithName("managed-comp"). + WithConditionType("Ready"). + WithResource(orphanTestResource("managed-cm"), OrphanWhen(false)). + Build() + require.NoError(t, err) + + assert.Empty(t, managed.orphanResources) + assert.Len(t, managed.reconcileResources, 1) + }) +} From 6b53c368170b40962927b56892ddf53f0c92c968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:30:21 +0200 Subject: [PATCH 3/5] feat(component): add orphanResources reconcile pass --- pkg/component/orphan.go | 74 ++++++++++++++++++++++++++++++++++++ pkg/component/orphan_test.go | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 pkg/component/orphan.go create mode 100644 pkg/component/orphan_test.go diff --git a/pkg/component/orphan.go b/pkg/component/orphan.go new file mode 100644 index 00000000..03497368 --- /dev/null +++ b/pkg/component/orphan.go @@ -0,0 +1,74 @@ +package component + +import ( + "context" + "errors" + "fmt" + + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// orphanResources releases the given resources from the owner: for each live +// object it removes the owner's controller owner reference and persists the +// change, leaving the object in the cluster. It never applies desired state and +// never deletes. +// +// Ownership is removed with a fetch-and-update of the live object rather than a +// Server-Side Apply: a minimal apply would drop the spec fields the component's +// field manager owns and revert the object's content, whereas editing only +// metadata.ownerReferences on the fetched object preserves everything else. +// +// A missing object or an already-absent owner reference is a no-op. Errors are +// gathered so every resource is attempted; conflicts are retried. +func orphanResources(ctx context.Context, rec ReconcileContext, resources []Resource) error { + var errs []error + ownerUID := rec.Owner.GetUID() + + for _, resource := range resources { + obj, err := resource.Object() + if err != nil { + errs = append(errs, fmt.Errorf("failed to get resource %s's underlying object on orphan: %w", resource.Identity(), err)) + continue + } + key := client.ObjectKeyFromObject(obj) + + var removed bool + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + live := obj.DeepCopyObject().(client.Object) + if getErr := rec.Client.Get(ctx, key, live); getErr != nil { + if apierrors.IsNotFound(getErr) { + return nil + } + return getErr + } + refs := live.GetOwnerReferences() + kept := make([]metav1.OwnerReference, 0, len(refs)) + removed = false + for _, ref := range refs { + if ref.UID == ownerUID { + removed = true + continue + } + kept = append(kept, ref) + } + if !removed { + return nil + } + live.SetOwnerReferences(kept) + return rec.Client.Update(ctx, live) + }) + if err != nil { + errs = append(errs, fmt.Errorf("failed to orphan resource %s: %w", resource.Identity(), err)) + continue + } + if removed { + rec.Recorder.Eventf(rec.Owner, v1.EventTypeNormal, "ResourceOrphaned", + fmt.Sprintf("Resource %s orphaned: owner reference removed", resource.Identity())) + } + } + return errors.Join(errs...) +} diff --git a/pkg/component/orphan_test.go b/pkg/component/orphan_test.go new file mode 100644 index 00000000..71b53ca9 --- /dev/null +++ b/pkg/component/orphan_test.go @@ -0,0 +1,67 @@ +package component + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func ownerRef(uid types.UID, name string) metav1.OwnerReference { + controller := true + return metav1.OwnerReference{APIVersion: "example.io/v1", Kind: "MockOperatorCRD", Name: name, UID: uid, Controller: &controller} +} + +func TestOrphanResources(t *testing.T) { + const ns = "test-namespace" + newOwner := func() *MockOperatorCRD { + return &MockOperatorCRD{ObjectMeta: metav1.ObjectMeta{Name: "test-owner", Namespace: ns, UID: "owner-uid"}} + } + seed := func(t *testing.T, owner *MockOperatorCRD, refs ...metav1.OwnerReference) (client.Client, ReconcileContext, *MockResource) { + scheme := setupScheme() + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(owner).Build() + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: ns, OwnerReferences: refs}} + require.NoError(t, fc.Create(t.Context(), cm.DeepCopy())) + res := &MockResource{} + res.On("Object").Return(&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: ns}}, nil) + res.On("Identity").Return("v1/ConfigMap/test-cm") + return fc, setupReconcileContext(scheme, owner, fc), res + } + getLive := func(t *testing.T, fc client.Client) *corev1.ConfigMap { + live := &corev1.ConfigMap{} + require.NoError(t, fc.Get(t.Context(), client.ObjectKey{Name: "test-cm", Namespace: ns}, live)) + return live + } + + t.Run("removes the owner controller reference and leaves the object", func(t *testing.T) { + fc, rc, res := seed(t, newOwner(), ownerRef("owner-uid", "test-owner")) + require.NoError(t, orphanResources(t.Context(), rc, []Resource{res})) + assert.Empty(t, getLive(t, fc).GetOwnerReferences()) + }) + t.Run("idempotent when owner reference already absent", func(t *testing.T) { + fc, rc, res := seed(t, newOwner()) + require.NoError(t, orphanResources(t.Context(), rc, []Resource{res})) + require.NoError(t, fc.Get(t.Context(), client.ObjectKey{Name: "test-cm", Namespace: ns}, &corev1.ConfigMap{})) + }) + t.Run("not found is a no-op", func(t *testing.T) { + owner := newOwner() + scheme := setupScheme() + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(owner).Build() + res := &MockResource{} + res.On("Object").Return(&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "missing", Namespace: ns}}, nil) + res.On("Identity").Return("v1/ConfigMap/missing") + require.NoError(t, orphanResources(t.Context(), setupReconcileContext(scheme, owner, fc), []Resource{res})) + }) + t.Run("removes only this owner's reference", func(t *testing.T) { + fc, rc, res := seed(t, newOwner(), ownerRef("owner-uid", "test-owner"), ownerRef("other-uid", "other-owner")) + require.NoError(t, orphanResources(t.Context(), rc, []Resource{res})) + live := getLive(t, fc) + require.Len(t, live.GetOwnerReferences(), 1) + assert.Equal(t, types.UID("other-uid"), live.GetOwnerReferences()[0].UID) + }) +} From 6cd4a41b4f74cf3fd801c36f9ad9b1ea4a34abe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:32:28 +0200 Subject: [PATCH 4/5] feat(component): release OrphanWhen resources during reconcile --- pkg/component/component.go | 12 +++++++ pkg/component/orphan_integration_test.go | 46 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pkg/component/component.go b/pkg/component/component.go index a3703926..1f7a3d09 100644 --- a/pkg/component/component.go +++ b/pkg/component/component.go @@ -323,6 +323,10 @@ func (c *Component) Reconcile(ctx context.Context, rec ReconcileContext) error { return fail(rec, c.conditionType, err) } + if err := orphanResources(ctx, rec, c.orphanResources); err != nil { + return fail(rec, c.conditionType, err) + } + cond := conditionDisabled(c.conditionType, rec.Owner.GetGeneration()) applyStatusCondition(rec, cond) return nil @@ -371,6 +375,10 @@ func (c *Component) Reconcile(ctx context.Context, rec ReconcileContext) error { return fail(rec, c.conditionType, err) } + if err := orphanResources(ctx, rec, c.orphanResources); err != nil { + return fail(rec, c.conditionType, err) + } + return nil } @@ -397,6 +405,10 @@ func (c *Component) Reconcile(ctx context.Context, rec ReconcileContext) error { return fail(rec, c.conditionType, err) } + if err := orphanResources(ctx, rec, c.orphanResources); err != nil { + return fail(rec, c.conditionType, err) + } + return nil } diff --git a/pkg/component/orphan_integration_test.go b/pkg/component/orphan_integration_test.go index 5b161270..3bd550e5 100644 --- a/pkg/component/orphan_integration_test.go +++ b/pkg/component/orphan_integration_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) // orphanTestResource returns a MockResource stubbed to act as a ConfigMap named @@ -47,3 +49,47 @@ func TestBuild_OrphanWhenPartition(t *testing.T) { assert.Len(t, managed.reconcileResources, 1) }) } + +func TestReconcile_OrphanWhenReleasesResource(t *testing.T) { + const ns = "test-namespace" + scheme := setupScheme() + owner := &MockOperatorCRD{ObjectMeta: metav1.ObjectMeta{Name: "test-owner", Namespace: ns, UID: "owner-uid"}} + + controller := true + live := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Name: "orphaned-cm", + Namespace: ns, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "example.io/v1", Kind: "MockOperatorCRD", Name: "test-owner", UID: "owner-uid", Controller: &controller}, + }, + }} + + fc := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(owner, live). + WithRESTMapper(createTestRESTMapper()). + Build() + rc := setupReconcileContext(scheme, owner, fc) + + c, err := NewComponentBuilder(). + WithName("orphan-comp"). + WithConditionType("Ready"). + WithResource(orphanTestResource("orphaned-cm"), OrphanWhen(true)). + Build() + require.NoError(t, err) + require.Len(t, c.orphanResources, 1) + + getLive := func() *corev1.ConfigMap { + got := &corev1.ConfigMap{} + require.NoError(t, fc.Get(t.Context(), client.ObjectKey{Name: "orphaned-cm", Namespace: ns}, got)) + return got + } + + // First reconcile: the owner reference is removed and the object remains. + require.NoError(t, c.Reconcile(t.Context(), rc)) + assert.Empty(t, getLive().GetOwnerReferences(), "owner reference should be removed") + + // Second reconcile: idempotent, object still present with no owner reference. + require.NoError(t, c.Reconcile(t.Context(), rc)) + assert.Empty(t, getLive().GetOwnerReferences()) +} From a2389cb153a8a9050b84a2402782e28439dc25d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:45:49 +0200 Subject: [PATCH 5/5] test(e2e): orphaned resource survives owner deletion Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/component/orphan_test.go | 151 +++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 e2e/component/orphan_test.go diff --git a/e2e/component/orphan_test.go b/e2e/component/orphan_test.go new file mode 100644 index 00000000..a26725b5 --- /dev/null +++ b/e2e/component/orphan_test.go @@ -0,0 +1,151 @@ +//go:build e2e + +package component + +import ( + "context" + + "github.com/sourcehawk/operator-component-framework/e2e/framework" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + + . "github.com/onsi/ginkgo/v2" //nolint:revive + . "github.com/onsi/gomega" //nolint:revive +) + +// The orphan test proves the architectural payoff that unit tests cannot show: +// once OrphanWhen(true) removes the controller owner reference, the object is no +// longer garbage-collected with its former owner and survives the owner's +// deletion. +// +// It uses a namespace-scoped TestApp as the owner (the cluster-scoped +// ClusterTestApp the rest of this suite relies on cannot own a namespace-scoped +// ConfigMap, so the API server would never set the owner reference in the first +// place). The component is built directly and reconciled against a hand-rolled +// ReconcileContext, mirroring how the framework reconcilers construct it, so the +// recCtx.Owner is the namespace-scoped TestApp whose UID the orphan pass matches. +var _ = Describe("OrphanWhen Garbage Collection", func() { + var ( + ns string + owner *framework.TestApp + recCtx component.ReconcileContext + ) + + BeforeEach(func() { + ns = framework.CreateTestNamespace(ctx, k8sClient, "e2e-orphan-") + + // A real namespace-scoped owner with a server-assigned UID. ConfigMaps in + // the same namespace that carry its controller owner reference are eligible + // for garbage collection when it is deleted. + owner = framework.NewTestApp(ctx, k8sClient, ns, "orphan-owner") + + recCtx = component.ReconcileContext{ + Client: k8sClient, + Scheme: scheme.Scheme, + Recorder: record.NewFakeRecorder(100), + Owner: owner, + } + }) + + AfterEach(func() { + // Best-effort cleanup; the namespace teardown removes anything left behind. + _ = k8sClient.Delete(ctx, &framework.TestApp{ + ObjectMeta: metav1.ObjectMeta{Name: "orphan-owner", Namespace: ns}, + }) + }) + + It("should orphan a resource so it survives deletion of its owner", func() { + // A control ConfigMap owned by the same owner but NOT orphaned. It proves + // garbage collection is actually wired up in this cluster: when the owner is + // deleted, the control object must disappear. Without it, a surviving orphan + // could be a false positive (GC simply not running). + control := newConfigMap(ns, "control-cm", map[string]string{"role": "control"}) + Expect(ctrl.SetControllerReference(owner, control, scheme.Scheme)).To(Succeed()) + Expect(k8sClient.Create(ctx, control)).To(Succeed()) + + // The orphan target: created already carrying the owner's controller owner + // reference, exactly as the component would have set it while managing it. + orphaned := newConfigMap(ns, "orphan-cm", map[string]string{"role": "orphan"}) + Expect(ctrl.SetControllerReference(owner, orphaned, scheme.Scheme)).To(Succeed()) + Expect(k8sClient.Create(ctx, orphaned)).To(Succeed()) + + By("verifying both ConfigMaps start with the owner reference") + Expect(ownerReferenceUIDs(ctx, ns, "control-cm")).To(ContainElement(owner.UID)) + Expect(ownerReferenceUIDs(ctx, ns, "orphan-cm")).To(ContainElement(owner.UID)) + + By("reconciling a component that orphans the target ConfigMap") + orphanRes, err := configmap.NewBuilder( + newConfigMap(ns, "orphan-cm", map[string]string{"role": "orphan"}), + ).Build() + Expect(err).NotTo(HaveOccurred()) + + comp, err := component.NewComponentBuilder(). + WithName("orphan-comp"). + WithConditionType("E2EReady"). + WithResource(orphanRes, component.OrphanWhen(true)). + Build() + Expect(err).NotTo(HaveOccurred()) + Expect(comp.Reconcile(ctx, recCtx)).To(Succeed()) + + By("verifying the orphan pass removed the owner reference") + Eventually(func(g Gomega) []types.UID { + return ownerReferenceUIDsG(g, ctx, ns, "orphan-cm") + }, framework.DefaultTimeout, framework.DefaultPolling). + ShouldNot(ContainElement(owner.UID)) + + By("verifying the control ConfigMap still carries the owner reference") + Expect(ownerReferenceUIDs(ctx, ns, "control-cm")).To(ContainElement(owner.UID)) + + By("deleting the owner") + Expect(k8sClient.Delete(ctx, owner)).To(Succeed()) + + By("waiting for garbage collection to remove the still-owned control ConfigMap") + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: "control-cm", Namespace: ns}, &corev1.ConfigMap{}) + }, framework.DefaultTimeout, framework.DefaultPolling). + ShouldNot(Succeed()) + + By("verifying the orphaned ConfigMap survives garbage collection") + Consistently(func() error { + return k8sClient.Get(ctx, types.NamespacedName{Name: "orphan-cm", Namespace: ns}, &corev1.ConfigMap{}) + }, "15s", framework.DefaultPolling). + Should(Succeed()) + + var survivor corev1.ConfigMap + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "orphan-cm", Namespace: ns}, &survivor)).To(Succeed()) + Expect(survivor.OwnerReferences).To(BeEmpty(), "surviving orphan must carry no owner reference") + Expect(survivor.Data).To(HaveKeyWithValue("role", "orphan")) + }) +}) + +// ownerReferenceUIDs fetches a ConfigMap by name and returns the UIDs of its +// owner references. It asserts the fetch succeeds. +func ownerReferenceUIDs(c context.Context, namespace, name string) []types.UID { + var cm corev1.ConfigMap + ExpectWithOffset(1, k8sClient.Get(c, types.NamespacedName{Name: name, Namespace: namespace}, &cm)).To(Succeed()) + return uidsOf(cm.OwnerReferences) +} + +// ownerReferenceUIDsG is the Eventually-friendly variant: it routes the fetch +// assertion through the supplied Gomega so a transient NotFound retries rather +// than failing the spec. +func ownerReferenceUIDsG(g Gomega, c context.Context, namespace, name string) []types.UID { + var cm corev1.ConfigMap + g.Expect(k8sClient.Get(c, types.NamespacedName{Name: name, Namespace: namespace}, &cm)).To(Succeed()) + return uidsOf(cm.OwnerReferences) +} + +func uidsOf(refs []metav1.OwnerReference) []types.UID { + uids := make([]types.UID, 0, len(refs)) + for _, ref := range refs { + uids = append(uids, ref.UID) + } + return uids +}