Problem
Two owners of one kind whose components render the same object no longer share a Server-Side Apply field manager (#197, #198), so for resources that carry a controller reference the second owner's apply is rejected by the API server and its component reports an error. That leaves two gaps. For a resource registered with Unowned(), or one whose owner reference cannot be set because of a scope mismatch, nothing carries the second owner's identity into the object, so its forced apply still takes the fields it declares and the fields move between the two owners' managers on every reconcile while both report converged. And where the apply is rejected, the outcome is an apply error with the API server's "Only one reference can have Controller set to true" text rather than a condition that says which owner holds the object. konsole-is/camunda-operator#262 works around this with a hand-written guard that reads the live object and blocks when another owner controls it; that logic belongs in the framework.
Approach
Add a resource option, component.BlockOnForeignController(), evaluated in reconcileResources after the resource's own guard and before the apply. It reads the live object through rec.Client, and when the object exists and has a controller reference whose UID is not rec.Owner.GetUID(), it records concepts.GuardStatusBlocked with a reason naming the controlling owner (controlled by <Kind> <name>) and stops the resources after it. It runs in the reconcile loop rather than as a WithGuard callback, because a custom guard receives only a copy of the desired object and no client. An object with no controller reference at all is not blocked; contention between two Unowned() owners is then still undetected, and the docs say so. Unlike other guards the check also covers every path that would delete the object: during suspension a resource another owner controls is neither scaled down nor deleted (it counts as suspended, so the component reports the usual Suspended condition), and a deletion asked for by Delete(), DeleteWhen(), GatedBy() or a disabled component feature gate is skipped; each skip is logged with the controlling owner. Combining the option with ReadOnly() is a build error. docs/component.md (guards, resource options) and the docs/primitives.md Server-Side Apply section, which previously said a shared name is the operator's responsibility and pointed at a hand-written guard, are updated.
Verification
- envtest spec: owner A reconciles a ConfigMap, owner B registers the same ConfigMap with the option; B's component reports
Blocked with a reason naming A, B performs no apply, and the ConfigMap's data, owner references and managedFields are unchanged.
- envtest spec: the same with both registrations
Unowned() and A having set a controller reference by other means; B is blocked. With no controller reference on the object, B is not blocked.
- envtest spec: once A's owner reference is removed (A orphans or deletes the object), B's next reconcile is unblocked and applies.
- Unit test that combining the option with
ReadOnly() is a build error, and that a suspended component, a disabled feature gate and a Delete() registration neither apply nor delete an object another owner controls.
make all passes; docs updated and make sync-plugin run.
Out of scope
Detecting contention between owners that both apply without a controller reference. Without any owner identity on the object there is nothing to compare; the docs state that a shared name between such owners is the operator's responsibility.
Context
Follow-up to #197 and #198. Consumer-side workaround and tests: konsole-is/camunda-operator#262.
Problem
Two owners of one kind whose components render the same object no longer share a Server-Side Apply field manager (#197, #198), so for resources that carry a controller reference the second owner's apply is rejected by the API server and its component reports an error. That leaves two gaps. For a resource registered with
Unowned(), or one whose owner reference cannot be set because of a scope mismatch, nothing carries the second owner's identity into the object, so its forced apply still takes the fields it declares and the fields move between the two owners' managers on every reconcile while both report converged. And where the apply is rejected, the outcome is an apply error with the API server's "Only one reference can have Controller set to true" text rather than a condition that says which owner holds the object. konsole-is/camunda-operator#262 works around this with a hand-written guard that reads the live object and blocks when another owner controls it; that logic belongs in the framework.Approach
Add a resource option,
component.BlockOnForeignController(), evaluated inreconcileResourcesafter the resource's own guard and before the apply. It reads the live object throughrec.Client, and when the object exists and has a controller reference whose UID is notrec.Owner.GetUID(), it recordsconcepts.GuardStatusBlockedwith a reason naming the controlling owner (controlled by <Kind> <name>) and stops the resources after it. It runs in the reconcile loop rather than as aWithGuardcallback, because a custom guard receives only a copy of the desired object and no client. An object with no controller reference at all is not blocked; contention between twoUnowned()owners is then still undetected, and the docs say so. Unlike other guards the check also covers every path that would delete the object: during suspension a resource another owner controls is neither scaled down nor deleted (it counts as suspended, so the component reports the usualSuspendedcondition), and a deletion asked for byDelete(),DeleteWhen(),GatedBy()or a disabled component feature gate is skipped; each skip is logged with the controlling owner. Combining the option withReadOnly()is a build error.docs/component.md(guards, resource options) and thedocs/primitives.mdServer-Side Apply section, which previously said a shared name is the operator's responsibility and pointed at a hand-written guard, are updated.Verification
Blockedwith a reason naming A, B performs no apply, and the ConfigMap's data, owner references andmanagedFieldsare unchanged.Unowned()and A having set a controller reference by other means; B is blocked. With no controller reference on the object, B is not blocked.ReadOnly()is a build error, and that a suspended component, a disabled feature gate and aDelete()registration neither apply nor delete an object another owner controls.make allpasses; docs updated andmake sync-pluginrun.Out of scope
Detecting contention between owners that both apply without a controller reference. Without any owner identity on the object there is nothing to compare; the docs state that a shared name between such owners is the operator's responsibility.
Context
Follow-up to #197 and #198. Consumer-side workaround and tests: konsole-is/camunda-operator#262.