Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions e2e/component/orphan_test.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 5 additions & 2 deletions pkg/component/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions pkg/component/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -322,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
Expand Down Expand Up @@ -370,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
}

Expand All @@ -396,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
}

Expand Down
74 changes: 74 additions & 0 deletions pkg/component/orphan.go
Original file line number Diff line number Diff line change
@@ -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...)
}
95 changes: 95 additions & 0 deletions pkg/component/orphan_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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"
"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
// 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)
})
}

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())
}
Loading
Loading