Skip to content

Commit 90da9d2

Browse files
committed
fix: use ownership instead of revision to guard teardown deletion
When an Owner is provided, check whether we are the controller via detectOwner rather than comparing revision annotations. Owner refs are the authoritative signal for ownership and correctly handle orphaning deletions where the revision annotation may have been updated by a different reconciliation pass. Callers without an Owner fall back to the existing revision comparison.
1 parent 42e572f commit 90da9d2

3 files changed

Lines changed: 182 additions & 13 deletions

File tree

machinery/objects.go

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -151,19 +151,10 @@ func (e *ObjectEngine) Teardown(
151151
return false, fmt.Errorf("getting object before deletion: %w", err)
152152
}
153153

154-
// Check revision matches.
155-
actualRevision, err := e.getObjectRevision(actualObject)
156-
if err != nil {
157-
return false, fmt.Errorf("getting object revision: %w", err)
158-
}
159-
160-
// Object is not owned by this revision
161-
if actualRevision != revision {
162-
if options.Owner == nil {
163-
// No Owner to remove from the object, return.
164-
return true, nil
165-
}
166-
154+
if options.Owner != nil {
155+
// Check ownership instead of revision to determine if we should delete.
156+
// If we're not the controller, only remove our owner ref and leave the object in place.
157+
// A possible reason for this could be an orphaning deletion.
167158
ctrlSit, _ := e.detectOwner(options.Owner, options.OwnerStrategy, actualObject, nil)
168159
if ctrlSit != ctrlSituationIsController {
169160
// Remove us from owners list:
@@ -173,6 +164,16 @@ func (e *ObjectEngine) Teardown(
173164
// TODO should we check if the patch differs from actualObject before firing the request?
174165
return true, e.writer.Patch(ctx, patch, client.MergeFrom(actualObject))
175166
}
167+
} else {
168+
// No Owner to check against. Fall back to revision comparison.
169+
actualRevision, err := e.getObjectRevision(actualObject)
170+
if err != nil {
171+
return false, fmt.Errorf("getting object revision: %w", err)
172+
}
173+
174+
if actualRevision != revision {
175+
return true, nil
176+
}
176177
}
177178

178179
// Actually delete the object.

machinery/objects_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,7 @@ func TestObjectEngine_Teardown(t *testing.T) {
10831083
modes []ownerMode // nil = both default modes
10841084
expectedGone bool
10851085
expectedError string
1086+
afterAssert func(t *testing.T, writer *testutil.CtrlClient)
10861087
}{
10871088
{
10881089
name: "Orphan",
@@ -1383,6 +1384,60 @@ func TestObjectEngine_Teardown(t *testing.T) {
13831384
},
13841385
expectedGone: true,
13851386
},
1387+
{
1388+
name: "Not controller, revision matches, removes owner ref",
1389+
revision: 1,
1390+
desiredObject: buildObj("testi", "test"),
1391+
actualObject: buildObj("testi", "test", withRevision("1"),
1392+
withOwnerRef("v1", "ConfigMap", "owner", "12345-678", false),
1393+
withOwnerRef("v1", "Node", "node1", "xxxx", true)),
1394+
modes: []ownerMode{withNativeOwnerMode},
1395+
mockSetup: func(
1396+
cache *cacheMock, writer *testutil.CtrlClient,
1397+
actualObject *unstructured.Unstructured,
1398+
) {
1399+
cache.
1400+
On("Get", mock.Anything,
1401+
client.ObjectKeyFromObject(actualObject),
1402+
mock.Anything, mock.Anything).
1403+
Run(func(args mock.Arguments) {
1404+
obj := args.Get(2).(*unstructured.Unstructured)
1405+
*obj = *actualObject
1406+
}).
1407+
Return(nil)
1408+
writer.On("Patch", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
1409+
},
1410+
expectedGone: true,
1411+
afterAssert: func(t *testing.T, writer *testutil.CtrlClient) {
1412+
t.Helper()
1413+
writer.AssertNotCalled(t, "Delete")
1414+
},
1415+
},
1416+
{
1417+
name: "Is controller, revision mismatch, still deletes",
1418+
revision: 1,
1419+
desiredObject: buildObj("testi", "test"),
1420+
actualObject: buildObj("testi", "test", withRevision("4"), withManaged),
1421+
modes: []ownerMode{withNativeOwnerMode},
1422+
mockSetup: func(
1423+
cache *cacheMock, writer *testutil.CtrlClient,
1424+
actualObject *unstructured.Unstructured,
1425+
) {
1426+
cache.
1427+
On("Get", mock.Anything,
1428+
client.ObjectKeyFromObject(actualObject),
1429+
mock.Anything, mock.Anything).
1430+
Run(func(args mock.Arguments) {
1431+
obj := args.Get(2).(*unstructured.Unstructured)
1432+
*obj = *actualObject
1433+
}).
1434+
Return(nil)
1435+
writer.
1436+
On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
1437+
Return(nil)
1438+
},
1439+
expectedGone: false,
1440+
},
13861441
}
13871442

13881443
for _, tc := range sharedTests {
@@ -1435,6 +1490,10 @@ func TestObjectEngine_Teardown(t *testing.T) {
14351490
require.Error(t, err)
14361491
assert.Contains(t, err.Error(), tc.expectedError)
14371492
}
1493+
1494+
if tc.afterAssert != nil {
1495+
tc.afterAssert(t, writer)
1496+
}
14381497
})
14391498
}
14401499
}

test/objects_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,3 +526,112 @@ func TestObjectEngine_StaleManagedFieldMigration(t *testing.T) {
526526
}
527527
}
528528
}
529+
530+
// TestObjectEngine_TeardownNotControllerRevisionMatch verifies that teardown
531+
// with an owner that is no longer the controller only removes the owner
532+
// reference and does NOT delete the object, even when the revision matches.
533+
func TestObjectEngine_TeardownNotControllerRevisionMatch(t *testing.T) {
534+
os := ownerhandling.NewNative(Scheme)
535+
comp := machinery.NewComparator(DiscoveryClient, Scheme, fieldOwner)
536+
oe := machinery.NewObjectEngine(
537+
Scheme, Client, Client, comp, fieldOwner, systemPrefix, "", nil,
538+
)
539+
540+
ctx := t.Context()
541+
542+
ownerA := &unstructured.Unstructured{
543+
Object: map[string]any{
544+
"apiVersion": "v1",
545+
"kind": "ConfigMap",
546+
"metadata": map[string]any{
547+
"name": "oe-teardown-owner-a",
548+
"namespace": "default",
549+
},
550+
},
551+
}
552+
ownerB := &unstructured.Unstructured{
553+
Object: map[string]any{
554+
"apiVersion": "v1",
555+
"kind": "ConfigMap",
556+
"metadata": map[string]any{
557+
"name": "oe-teardown-owner-b",
558+
"namespace": "default",
559+
},
560+
},
561+
}
562+
563+
require.NoError(t, Client.Create(ctx, ownerA, client.FieldOwner(fieldOwner)))
564+
cleanupOnSuccess(t, ownerA)
565+
require.NoError(t, Client.Create(ctx, ownerB, client.FieldOwner(fieldOwner)))
566+
cleanupOnSuccess(t, ownerB)
567+
568+
configMap := &unstructured.Unstructured{
569+
Object: map[string]any{
570+
"apiVersion": "v1",
571+
"kind": "ConfigMap",
572+
"metadata": map[string]any{
573+
"name": "oe-teardown-not-ctrl",
574+
"namespace": "default",
575+
},
576+
"data": map[string]any{
577+
"key": "value",
578+
},
579+
},
580+
}
581+
582+
// Step 1: Create the object owned by ownerA at revision 1.
583+
res, err := oe.Reconcile(ctx, 1, configMap, types.WithOwner(ownerA, os))
584+
require.NoError(t, err)
585+
assert.Equal(t, machinery.ActionCreated, res.Action())
586+
587+
// Step 2: ownerB adopts the object (becomes controller, ownerA becomes non-controller).
588+
res, err = oe.Reconcile(ctx, 1, configMap,
589+
types.WithOwner(ownerB, os),
590+
types.WithCollisionProtection(types.CollisionProtectionNone),
591+
)
592+
require.NoError(t, err)
593+
assert.Equal(t, machinery.ActionUpdated, res.Action())
594+
595+
// Verify ownerB is now the controller and ownerA's ref is still present.
596+
actual := configMap.DeepCopy()
597+
require.NoError(t, Client.Get(ctx, client.ObjectKeyFromObject(configMap), actual))
598+
assert.True(t, os.IsController(ownerB, actual), "ownerB should be controller")
599+
assert.False(t, os.IsController(ownerA, actual), "ownerA should not be controller")
600+
601+
// Step 3: Teardown with ownerA at revision 1 — revision matches but
602+
// ownerA is not the controller. The object must NOT be deleted.
603+
gone, err := oe.Teardown(ctx, 1, configMap, types.WithOwner(ownerA, os))
604+
require.NoError(t, err)
605+
assert.True(t, gone)
606+
607+
// Verify the object still exists on the cluster.
608+
stillExists := configMap.DeepCopy()
609+
err = Client.Get(ctx, client.ObjectKeyFromObject(configMap), stillExists)
610+
require.NoError(t, err, "object must still exist after teardown by non-controller owner")
611+
612+
// Verify ownerA's ref has been removed but ownerB's ref remains.
613+
hasOwnerA := false
614+
hasOwnerB := false
615+
616+
for _, ref := range stillExists.GetOwnerReferences() {
617+
if ref.UID == ownerA.GetUID() {
618+
hasOwnerA = true
619+
}
620+
621+
if ref.UID == ownerB.GetUID() {
622+
hasOwnerB = true
623+
}
624+
}
625+
626+
assert.False(t, hasOwnerA, "ownerA ref should have been removed")
627+
assert.True(t, hasOwnerB, "ownerB ref should still be present")
628+
629+
// Cleanup: teardown with ownerB (the actual controller).
630+
gone, err = oe.Teardown(ctx, 1, configMap, types.WithOwner(ownerB, os))
631+
require.NoError(t, err)
632+
assert.False(t, gone)
633+
634+
gone, err = oe.Teardown(ctx, 1, configMap, types.WithOwner(ownerB, os))
635+
require.NoError(t, err)
636+
assert.True(t, gone)
637+
}

0 commit comments

Comments
 (0)