Repository navigation
feat(component): add DeleteOnlyIfOwned to keep objects the owner does not own - #239
Conversation
… not own A gated-off resource was deleted by name, so an object a user created under the same name was deleted too. With DeleteOnlyIfOwned, a deletion reads the live object and deletes it only when it carries an owner reference with the owner's UID. The delete carries UID and resourceVersion preconditions. An object without the reference, or one that changed after the read, is kept and logged, and the reconcile does not fail. The option covers Delete, DeleteWhen, GatedBy, a disabled component feature gate and DeleteOnSuspend. It is a build error with ReadOnly or Unowned. Closes #233 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Suspension can adopt and then delete an initially unowned object despite the new protection.
1 open finding
What changed in this PR
Adds opt-in ownership checks to component deletion, protecting same-name user-created objects as requested in #233.
Changes:
- Adds
DeleteOnlyIfOwned()with validation and guarded deletion. - Shares owner-reference matching and adds unit and integration coverage.
- Updates documentation and plugin guidance.
| File | Description |
|---|---|
| plugin/skills/structuring-operators/SKILL.md | Adds gated-resource safety guidance. |
| plugin/skills/structuring-operators/references/guidelines.md | Syncs operator guidance. |
| plugin/skills/building-components/SKILL.md | Documents the new option. |
| plugin/skills/building-components/references/component.md | Syncs option documentation. |
| pkg/component/suspend.go | Updates deletion commentary. |
| pkg/component/resource_options.go | Defines and validates the option. |
| pkg/component/resource_options_test.go | Tests resolution and invalid combinations. |
| pkg/component/orphan.go | Extracts shared ownership matching. |
| pkg/component/delete.go | Checks ownership and handles delete conflicts. |
| pkg/component/delete_test.go | Tests guarded deletion behavior. |
| pkg/component/delete_owned_test.go | Covers deletion triggers with integration tests. |
| docs/guidelines.md | Explains safe gated deletion. |
| docs/component.md | Documents behavior and limitations. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
With DeleteOnlyIfOwned and DeleteOnSuspend, the suspension apply set the owner reference on a user-created object, and the delete after it then found the object owned and deleted it. The suspension now checks the owner reference before Suspend and the apply, and an object without it counts as suspended with no write. The read goes through the API reader when set, so a stale cache that still shows an earlier owned object does not let the apply take over a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review round 1 (review base 687b35e, review effort: Balanced, from the Findings: one posted finding (delete-on-suspend adopts and then deletes an unowned object). It was correct and is fixed in b70ff47. The review had no suppressed comments. Comment verdicts for this round (evaluator: a fresh agent that did not see the finding):
Comments with no flag: Docs: |
The delete-on-suspend existence check read the live object into a copy of the desired object. The ownership check then depended on the decoder clearing ownerReferences that the copy carried from an earlier apply. The read now goes into an empty object of the same type, as observeController does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review round 2 (review base b70ff47, review effort: Balanced, from the Findings: one posted finding (read the suspension ownership check into an empty object). Applied in 79b56dc. I could not reproduce the stale reference with envtest or the fake client, but the change matches Comment verdicts for this round: the gate lists no flag. One |
With DeleteOnlyIfOwned and DeleteOnSuspend, an object that was replaced or disowned between the ownership read and the suspension apply was taken over by the apply and then deleted. The suspension apply now carries the observed resourceVersion, so the API server rejects it when the object changed. The resource then reports Suspending, the object is not written, and the reconcile does not fail. The deleteResources doc now says that an entry left in place is not an error, which the skip paths already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review round 3 (review base 79b56dc, review effort: Balanced, from the Findings: one posted finding (bind the suspension apply to the ownership read). I first declined it. I then fixed it in d170fdf, because the race deletes the object the option exists to keep. The thread reply has the details. The review had no suppressed comments. Comment verdicts (evaluator: a fresh agent that did not see the finding):
Comments with no flag: Proposals from the final gate against origin/main that I did not apply: the other |
Add a test for a precondition conflict on the delete when DeleteOnlyIfOwned and BlockOnForeignController are both set. The reconcile does not fail and the object is kept. BlockOnForeignController alone still fails on that conflict, so the combined case needs its own test. Move the owner reference check of the delete and suspension paths into one ownedBy helper. Split the DeleteOnlyIfOwned GoDoc into paragraphs, and give the suspension apply comment a subject. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Description
Closes #233
A gated-off resource (
GatedBy,DeleteWhen, a disabled component feature gate) is deleted by name, so an object that a user created under the same name is deleted too. This PR adds theDeleteOnlyIfOwned()resource option: a deletion first reads the live object and deletes it only when the object carries an owner reference with the owner's UID, with UID and resourceVersion preconditions on the delete. Without the option, the behavior is unchanged.Changes
component.DeleteOnlyIfOwned()resource option. It applies to every path that goes throughdeleteEntry:Delete(),DeleteWhen(),GatedBy(), a disabled component feature gate, and suspension withDeleteOnSuspend().ResourceDeletedevent and no reconcile error. The skip is logged.DeleteOnSuspend(), the suspension checks the owner reference beforeSuspend()and the apply, because that apply would set the reference and the delete after it would then find the object owned. An object without the reference is not written and counts as suspended. The suspension apply also carries the observed resourceVersion, through a new internalwithResourceVersionapply option. If the object changes between the read and the apply, the apply is rejected, the resource reportsSuspending, and the reconcile does not fail.DeleteOnlyIfOwned()withReadOnly()orUnowned()is a build error, because such a resource never carries the owner reference.BlockOnForeignController()keeps its documented meaning. With both options, an object another owner controls is kept, and a conflict does not fail the reconcile.orphanResourcesused inline is now a sharedreferencesOwnerpredicate, used by both paths.docs/component.md(option table and a section for the option),docs/guidelines.md(theGatedByguidance), and thebuilding-componentsandstructuring-operatorsskills. Plugin references are synced.Challenges
The design choices that the issue did not settle:
OrphanWhenfinds the owner's reference, and it means an object co-owned through a non-controller reference is still deleted. To keep an object that another owner controls, addBlockOnForeignController().BlockOnForeignController():ReconcileContext.APIReaderwhen set, the cached client otherwise. The preconditions make the delete safe with a stale cache too. The fresh read only makes a wrong keep or a conflict less likely.DeleteOnlyIfOwned(), a 409 from the precondition is not an error, as the issue asks. WithBlockOnForeignController()alone, the conflict is still returned, so that option's behavior does not change.ResourceDeletedevent, and it is recorded only when the delete happened.docs/component.mdand not rejected.Testing
Unit tests in
pkg/component/delete_test.go(fake client) cover: delete of an owned object with an event; delete through a non-controller owner reference; the UID and resourceVersion preconditions on the delete call; keep of an object with no owner reference (no Delete call, same resourceVersion, no event); keep of an object that only another owner references; an absent object; a conflict after the read (no error, object kept, no event); a non-conflict delete error still returned; and the combination withBlockOnForeignController(), both for the foreign-controller skip and for a conflict on the delete (no error, unlikeBlockOnForeignController()alone).suspend_test.gocovers a suspension where the cache shows the object owned and the API reader does not. I ran the new tests against the olddeleteEntryfirst and they failed.Envtest specs in
pkg/component/delete_owned_test.gorun each ofGatedBy,DeleteWhen(true)and a disabled component feature gate three ways: an owned object is deleted, a user-created object is kept unchanged, and without the option the user-created object is still deleted. Three more specs cover suspension withDeleteOnSuspend(): an owned object is deleted, a user-created object keeps its resourceVersion while the component reportsSuspended, the same holds when the desired object still carries the owner reference from an earlier apply, and an object disowned between the read and the suspension apply is not written or deleted. Option resolution tests cover the flag and the two build errors.make allpasses.🤖 Generated with Claude Code