feat(component): block a resource whose live object is controlled by another owner - #201
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The suspension-path reason string for foreign-controlled objects is inconsistent with the documented/advertised “same reason” behavior and should be aligned before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces a new component resource option, component.BlockOnForeignController(), to prevent a component from applying (or suspending/deleting during suspension) a resource whose live object is already controlled by another owner via a controller owner reference. This turns otherwise noisy/opaque apply failures (or silent field takeovers in Unowned()/scope-mismatch cases) into an explicit Blocked condition with a readable reason.
Changes:
- Add
component.BlockOnForeignController()resource option with validation (mutually exclusive withReadOnly()). - Extend reconciliation and suspension flows to detect a foreign controller owner reference on the live object and short-circuit with
Blocked/Suspendedwithout applying or deleting. - Add envtest and unit tests, and update framework docs + synced plugin references to describe the new behavior and the remaining undetected contention case.
File summaries
| File | Description |
|---|---|
pkg/component/create.go |
Adds the pre-apply foreign-controller check (foreignController) and blocks reconciliation with a GuardBlocked-equivalent status when another owner controls the live object. |
pkg/component/resource_options.go |
Introduces the option flag, public BlockOnForeignController() option, and validation that rejects combination with ReadOnly(). |
pkg/component/suspend.go |
Adds a suspension-path check to avoid scaling down or deleting objects controlled by another owner. |
pkg/component/resource_options_test.go |
Adds option resolution and validation coverage for BlockOnForeignController. |
pkg/component/suspend_test.go |
Adds unit coverage ensuring suspension doesn’t touch a foreign-controlled object and still suspends normally when controlled by self/no controller. |
pkg/component/create_foreign_controller_test.go |
Adds envtest specs validating Blocked behavior and that the live object remains unchanged when blocked. |
docs/component.md |
Documents the new option and its interaction with guards and suspension behavior. |
docs/primitives.md |
Updates SSA section to recommend the new option and clarify the remaining contention gap for controller-less cases. |
plugin/skills/building-components/references/component.md |
Sync of the updated docs/component.md into plugin references. |
plugin/skills/using-primitives/references/primitives.md |
Sync of the updated docs/primitives.md into plugin references. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…another owner (#199) Add the BlockOnForeignController resource option. Before each apply the component reads the live object and, when it carries a controller reference to an owner other than the reconciling one, records Blocked with "controlled by <Kind> <name>" and performs no apply, like any blocked guard. During suspension such a resource is neither scaled down nor deleted and reports Suspended with the same reason. Combining the option with ReadOnly is a build error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
…rolled resource (#199) The suspension path names the resource and the controlling owner in the style of the other suspension reasons; the GoDoc and docs claimed it was the same string as the reconcile-time block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
d4e56c2 to
a040102
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Cached reads can miss a foreign controller and permit an Unowned() forced apply, while public discovery documentation also remains incomplete.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
pkg/component/resource_options.go:193
- The new public option is missing from the exported
Builder.WithResourceoption list (builder.go:116-118) and fromplugin/skills/building-components/SKILL.md:52-56; the distilled SSA guidance inplugin/skills/using-primitives/SKILL.md:177-181also still ends at the old undetected-contention behavior. These published discovery surfaces should be updated alongside the API and generated references so consumers can find and correctly understand the option.
func BlockOnForeignController() ResourceOption {
return func(c *resourceConfig) { c.blockOnForeignController = true }
pkg/component/create.go:402
- This safety check reads through the manager's cached
Client, so it can observe the pre-controller state while the API server already has a foreign controller reference. In theUnowned()case the subsequent forced apply can then take the foreign owner's fields before the cache catches up, defeating the option's core guarantee. Preferrec.APIReaderwhen configured (as the status-conflict path already does), withClientas the fallback.
if err := rec.Client.Get(ctx, client.ObjectKeyFromObject(obj), live); err != nil {
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…surface the option (#199) The foreign-controller check read through the cached client, which can still hold the object without the controller reference the API server already carries; for an Unowned resource the forced apply would then take the other owner's fields, the case the option exists to stop. Read through ReconcileContext.APIReader when set, as the status-conflict path does. Name BlockOnForeignController in the WithResource GoDoc and in the plugin skills that list resource options and describe Server-Side Apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
|
Round 2 of the Copilot review posted no comments and suppressed two findings; both were verified and applied in c5a3668:
|
There was a problem hiding this comment.
🟡 Changes recommended
The controller check can read stale cached state and permit the field takeover it is intended to prevent.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
The read/apply sequence remains racy, and suspension aggregation discards the documented foreign-owner reason.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
pkg/component/suspend.go:124
- This owner-specific reason never reaches the component condition.
Component.Reconcileaggregates these results throughsuspensionResults.summary(), which replaces every fully-suspended result with"All resources are suspended."; the new test only callssuspendResourcedirectly and misses that public path. Preserve this exceptional reason during aggregation and cover a suspended component reconcile so the documented owner name is actually reported.
return concepts.SuspensionStatusWithReason{
Status: concepts.SuspensionStatusSuspended,
Reason: fmt.Sprintf(
"Resource %s is %s; nothing to suspend.", resource.Identity(), foreignControllerReason(controller),
),
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
…ing touched (#199) The per-resource suspension reason is folded into "All resources are suspended." by the aggregation, so the controlling owner never reached the component condition while the docs said it did. Log the owner at the skip, describe the condition the component actually reports, and cover the suspended path through Component.Reconcile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
|
Round 3 of the Copilot review suppressed one finding; verified and applied in 4dd72de:
The posted comment on the read/apply race was declined in its thread with the reasoning. |
There was a problem hiding this comment.
🟡 Changes recommended
Feature-gate and explicit deletion paths can still delete an object protected by the new option.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
…th (#199) The delete list carried bare resources, so BlockOnForeignController was lost once a resource was marked for deletion, gated off, or the component gate turned off, and the object the option protects could still be deleted. Delete entries now carry their options and deleteResources skips an entry whose live object another owner controls, logging that owner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
There was a problem hiding this comment.
🟡 Changes recommended
Non-atomic ownership checks can still delete an object that becomes foreign-controlled before deletion.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 19/19 changed files
- Comments generated: 2
- Review effort level: Balanced
A delete decided on an earlier read could still remove an object that another owner claimed in between, and unlike a lost apply race a lost delete is not repaired by the next reconcile. For an entry registered with BlockOnForeignController the delete now re-observes the live object and carries its UID and resourceVersion as preconditions, on the deletion flags, the disabled gate and delete-on-suspend alike; an absent object counts as deleted and a claimed one is left in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
…d technical English (#199) Short sentences, one topic per paragraph, active voice, no semicolons, in the option docs, the guards section, the Server-Side Apply section and the two plugin skills that mention the option. No behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS
There was a problem hiding this comment.
🟡 Changes recommended
The option’s GoDoc overpromises protection for orphaning writes that bypass the controller check.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 19/19 changed files
- Comments generated: 1
- Review effort level: Balanced
| // The object is protected on every path that would write or delete it, not | ||
| // only the apply. During suspension the resource is not applied or deleted |
Description
Closes #199
A second owner whose component renders an object that another owner already controls either fails with the API server's "Only one reference can have Controller set to true" text, or, for an
Unowned()or scope-mismatched resource, silently takes the object's fields on every reconcile. This PR adds thecomponent.BlockOnForeignController()resource option: before each apply the component reads the live object and, when it carries a controller reference to another owner, reportsBlockedwithcontrolled by <Kind> <name>and performs no apply, like any blocked guard. It compares controller references only, so two owners that both apply without one are still undetected; the docs state that a shared name is the operator's responsibility in that case.Changes
component.BlockOnForeignController()resource option; combining it withReadOnly()is a build error.reconcileResourceschecks the live object's controller reference after the resource's own guard and before the apply, reading throughReconcileContext.APIReaderwhen set so a stale cache cannot let anUnowned()apply through, and recordingGuardBlockedand short-circuiting the remaining resources.Suspendedcondition), and a deletion asked for byDelete()/DeleteWhen()/GatedBy()or a disabled component feature gate is skipped. Each skip is logged with the controlling owner, and a delete of an object observed as safe carries the observed UID and resourceVersion as preconditions, so an owner that claims the object in between keeps it. Delete entries now carry their resolved options to make this possible.docs/component.md(options table, guards) and thedocs/primitives.mdServer-Side Apply section describe the option and the remaining gap; plugin references synced.Related
Testing
Eight new envtest specs in
pkg/component/create_foreign_controller_test.go: the second owner is blocked with the reason naming the first while the ConfigMap's data, owner references, managedFields and resourceVersion stay unchanged; anUnowned()registration is blocked by a controller reference set by other means and not blocked when there is none; the controlling owner itself is never blocked; the block clears once the reference is removed; a suspended component leaves the other owner's object untouched; and so do a disabled feature gate and aDelete()registration. Unit tests cover option resolution, the suspension path with a fake client (noSuspend, apply or delete reaches an object another owner controls), the check reading throughAPIReaderwhen the cache is stale, and a delete (flag and delete-on-suspend) that loses the race to a claim at the moment of the delete, which leaves the object in place.make allpasses (lint 0 issues). No E2E suite exercises the new option, so none was run.🤖 Generated with Claude Code
https://claude.ai/code/session_01MZRpj8ChzesLeqLkWT31vS