Skip to content

LockedResourceReconciler: enforce with server-side apply; excluded paths by ownership; template cache concurrency-safe - #104

Open
ephico2real2 wants to merge 9 commits into
redhat-cop:masterfrom
ephico2real2:feat/ssa-enforcer
Open

ephico2real2 wants to merge 9 commits into
redhat-cop:masterfrom
ephico2real2:feat/ssa-enforcer

Conversation

@ephico2real2

@ephico2real2 ephico2real2 commented Sep 5, 2026

Copy link
Copy Markdown

Summary

LockedResourceReconciler enforces with server-side apply instead of a merge patch.

Every reconcile applies the rendered object under one field manager (lockedresourcecontroller) with force. Drift on an owned field is restored; a field the template no longer renders is removed (the server deletes what its owner stops sending); a field another manager owns and the template never renders is left alone; an apply that changes nothing does not write (resourceVersion unchanged). The diff, and the jsondiff dependency, are gone.

excludedPaths keep their meaning, "set once, then left alone", by ownership rather than by omission: the object is created from the whole rendered object, and before every later apply the reconciler drops every path under an excluded path from its own managedFields entry (one JSON patch, sent only when something changes, replacing resourceVersion so a concurrent write conflicts). The server then keeps the field as it is, owned by nobody. The unit released is the one the server tracks: learned from a dry-run apply of the rendered object, once per reconciler, so an exclusion inside an atomic map or list (RBAC rules) excludes that unit.

Optional, off by default: LegacyFieldManagers names client-side Update entries to fold into the apply manager on first contact (client-go's csaupgrade), so objects created by the previous enforcer lose the fields their template no longer renders. A consumer that knows its history opts in; the fold takes the whole entry of that name, which is why it is not automatic.

A locked resource's identity (GetKey) now includes its excluded paths, so an excludedPaths edit on a CR restarts its reconciler without an operator restart (it did not before). Also carried: a concurrency-safe template cache (one parsed base per text, cloned and rebound to each caller's config) and excluded-path handling that converts indexes and quoted keys correctly and reports malformed paths instead of silently retargeting them.

Why

A merge patch cannot express "this field is no longer rendered". redhat-cop/namespace-configuration-operator#194 reports the symptom (a ResourceQuota field set to 0 survives after its conditional stops rendering it); I proposed the fix in #103 by injecting nulls and computing a diff, and the review of #103 suggested server-side apply as the simpler mechanism. This PR is that rework of my fix. Field ownership is the API server's answer to both that and to the .metadata problem consumers work around by excluding it wholesale (a foreign label was a permanent difference under the merge patch).

Proof

Every claim is an envtest-backed test in pkg/util/lockedresourcecontroller/resource-reconciler_ssa_test.go (make test, or KUBEBUILDER_ASSETS=... go test -race ./pkg/util/lockedresourcecontroller/...): create and restore drift; a dropped field removed; an excluded field set at creation, changed by someone else and kept through further reconciles with the reconciler not owning it; .metadata excluded keeps the template's labels and a stranger's; a legacy entry folded once; a no-op reconcile keeps the resourceVersion; another manager's field left; an atomic map's excluded child survives a fresh reconciler; an index into a map-keyed list releases the list; a key named -1 set once. Also run on a live OpenShift cluster with the namespace-configuration-operator built against this branch.

Three adversarial review passes with independent reviewers are recorded in docs/REVIEW_ssa-enforcer.md, with every verdict re-measured and each decision written down. Supersedes #103.

Resolves #105.

The same files as on fix/template-cache-race-and-excluded-paths at bcf5ba5, without the #194
null-injection change to resource-reconciler.go: a concurrency-safe template cache holding one
parsed base per text that is cloned and rebound to each caller's config, and excluded-path
handling that converts indexes and quoted keys correctly, rejects negative indexes, malformed
brackets and empty segments by name, and passes RFC 6901 pointers through. Every case has a
test that failed before its fix.
The merge-patch enforcer could not express "a field the template no longer renders": a merge
patch only adds and replaces, so a stale field survived until #194 proposed injecting nulls and
computing diffs. Field ownership is the API server's answer to exactly that. Every reconcile now
applies the rendered object under one field manager with force: drift on an owned field is
restored, a field the template stops rendering is removed (the server deletes what its owner
stops sending), a field another manager owns and the template never renders is left alone, and
an apply that changes nothing does not write (resourceVersion unchanged). The diff, the null
injection and the jsondiff dependency are gone.

Excluded paths keep their meaning, "set once, then left alone", by ownership rather than by
omission: the object is created from the whole rendered object, and before every later apply the
reconciler drops every path under an excluded path from its own managedFields entry, so the server
keeps the field as it is, owned by nobody, instead of deleting it when the reduced object arrives.
The same step folds a client-side Update entry left by the previous enforcer (the binary's name,
"manager" on a live cluster, taken from the user agent) into the apply manager with client-go's
csaupgrade, so objects created before this change lose their stale fields too. One JSON patch,
sent only when something changes, replacing resourceVersion so a concurrent write conflicts
instead of being overwritten.

Every claim is a test against a real API server (envtest): create and restore drift; a dropped
field removed; an excluded field set at creation, changed by someone else, and kept through
further reconciles with the reconciler not owning it; `.metadata` excluded keeps the template's
labels and a stranger's; a legacy Update entry folded, its stale field removed, its excluded
labels kept; a no-op reconcile keeps the resourceVersion; another manager's field left. The
package's TestMain skips them with a message when KUBEBUILDER_ASSETS is unset.
The enforcer restarts a reconciler only for a resource whose key changed, and the key was the
marshalled object alone, so editing a template's excludedPaths on a CR changed nothing until the
operator restarted. Measured on a cluster: after `.metadata` was removed from a NamespaceConfig's
excludedPaths, its reconcilers kept excluding it and a tampered label was not restored. The key
now carries the sorted excluded paths. A changed key restarts the manager without deleting
anything: the deletion set is computed by object identity (getToBeDeletdResources), not by key.
… "equal" verdict races the right object, lists measured

FieldPath now says which segment the path wrote as a list index; before, any numeric segment
was taken for one, so excluding ".data['0']" released all of data (measured). A quoted key is
never an index; "[0]", a bare ".0" and a numeric pointer segment are.

releaseOwnership returns the object the apply then races (the patched one, or the live one
when nothing was sent), and Reconcile compares the apply's result with that: a release followed
by a no-op apply logged "NOT equal" before, because the comparison used the first GET.

Tests, all against envtest: the quoted numeric key set once and its sibling enforced; the log
verdict after a release; Role rules as an atomic list (drift inside the list restored, a
dropped element removed, and an exclusion inside the list documented as removing that element
on the next reconcile, as the merge-patch enforcer did). The residual risk of folding a whole
legacy entry (another controller with the same binary name writing the same object) is stated
next to LegacyFieldManagers and accepted: folding only what the template renders would leave
every stale field of a legacy object in place, the failure this change exists to end.
…p unit; the legacy fold is opt-in

managedFields is schema-shaped: a granular map lists its keys, an atomic map (a Pod's
nodeSelector) or an atomic list (RBAC rules) is one member with no children. An exclusion inside
such a unit could not be released on its own, and applying the unit without the excluded child
deleted the child (measured in review). Each excluded path now widens to the unit the server
tracks when nothing is owned at or below it but a strict ancestor is; that unit is released and
left out of the apply, "set once" at the granularity the server allows. Tests against envtest: a
nodeSelector child survives and the map is not owned; an exclusion inside a Role's rules leaves
the whole list alone (before this pass the excluded element was deleted on the next reconcile).

LegacyFieldManagers is empty by default: the library folds nothing unless the consumer names a
manager. Both reviewers rated the automatic fold the most important finding: a whole entry of
that name is taken, so a field another actor wrote under the same binary name would be removed,
which the merge-patch enforcer never did. A consumer that knows its history opts in; the
namespace-configuration-operator names "manager". Test: with the default, a legacy entry is
left alone and its field survives while the rendered field is enforced.
…t pass verdicts, decisions, live measurements
…conciler; the reduced object is built by name

Both second-pass reviewers found the same defect: the widening read live ownership as if it were
schema. After a granular exclusion was released, its parent looked like an atomic ancestor and the
next reconcile widened to the whole map (a ConfigMap's data stopped being enforced); after an
atomic map was released nothing recorded that it was atomic, so a later reconcile, or a restarted
operator, sent the map without its excluded child and the server deleted the child.

The evidence is now the server's own answer for the rendered object: a dry-run apply of the whole
object returns the managedFields it would record, schema-shaped, and the unit of each excluded
path is the deepest owned ancestor when that ancestor has no children, else the path itself.
Computed once per reconciler (the rendered object does not change for its lifetime). The reduced
object is built by deleting the units by name rather than through a JSON pointer, which rejected
a ConfigMap key named "-1" (measured). An index into a map-keyed list (Deployment containers)
still releases the whole list: the merge key is schema the reconciler does not have; documented
and tested (the list is set once, nothing deleted; fields outside it stay enforced).

Tests, all against envtest: the granular sibling stays enforced through further reconciles; the
atomic map survives a fresh reconciler with no memory; a key named "-1" is set once; a Deployment
with an index exclusion keeps both containers and keeps owning replicas; the fold writes nothing
on its second reconcile.
ephico2real2 added a commit to ephico2real2/namespace-configuration-operator that referenced this pull request Sep 5, 2026
… the SSA enforcer

25 checks on the sandbox against v1.2.6-142; results posted on redhat-cop/operator-utils#104.
@ephico2real2

Copy link
Copy Markdown
Author

Live validation through a consumer

Ran against a cluster (OpenShift 4.x, single node) with the namespace-configuration-operator fork built on this branch's code: operator v1.2.6-142-ga8b0ea9 (image digest sha256:5447cd413962…), go.mod replace to ephico2real2/operator-utils ab28e2b (= this PR's head 26f5ee7 merged). Script: hack/validate-ssa-live.sh. Ten scenarios, one per sentence of the description; each check is an oc measurement with a 60 s ceiling. The probe is one NamespaceConfig rendering a ConfigMap (excludes .data.keep), a Role (excludes .rules[0]) and a ConfigMap pre-created by a client-side manager named manager with a field the template does not render.

Scenario Measured Result
S0 legacy object pre-created by manager 'manager' managers before: manager/Update ok
S1 objects created from the whole rendered object got a=1,b=2,keep=rendered,zero=0 after 6 s ok
S1 only field manager is the apply manager got lockedresourcecontroller/Apply after 2 s ok
S1 reconciler owns a rendered field (data.a) got yes after 2 s ok
S1 reconciler released the excluded field (data.keep) got no after 2 s ok
S2 tampered rendered label restored got yes after 2 s ok
S2 tampered rendered value restored got 1 after 2 s ok
S3 (reconcile happened: a restored again) got 1 after 2 s ok
S3 foreign label kept through the reconcile still keep after 5 s ok
S3 foreign data key kept through the reconcile still x after 1 s ok
S4 (reconcile happened: a restored) got 1 after 4 s ok
S4 excluded data.keep stays as changed by hand still changed after 5 s ok
S5 b and zero removed; foreign extra and hand-set keep untouched got a=1,extra=x,keep=changed after 6 s ok
S6 hand-added verb in rules[0] kept (whole rules released) still get,watch after 8 s ok
S6 rendered element 0 still present (not deleted) got pods after 2 s ok
S6 reconciler does not own rules (entry gone: it owned nothing else) got no-entry after 2 s ok
S7 rules now enforced: hand-added verb removed got get after 6 s ok
S7 reconciler owns rules now got yes after 2 s ok
S8 stale field written by 'manager' removed, rendered data kept got a=1 after 2 s ok
S8 no 'manager/Update' entry remains got lockedresourcecontroller/Apply after 2 s ok
S9 probe CR reconciled by the new pod got True after 16 s ok
S9 probe objects' resourceVersions unchanged across the restart kube-root-ca.crt=35897607 openshift-service-ca.crt=35897629 probe-cm=35898245 probe-legacy=35897796 probe-role=35898523 ok
S9 chart-managed objects present after restart 40 of 40 ok
S9 operator error lines after restart 1 (the known startup 409 on a stale test UserConfig, if 1) ok
S10 probe objects removed by the finalizer got 0 after 2 s ok

25 ok, 0 failed — operator image sha256:5447cd413962, VERSION: v1.2.6-142-ga8b0ea9

Notes on two rows:

  • S5 is Bug: Operator does not differentiate between value 0 and missing field namespace-configuration-operator#194 end to end: the keys b and zero (zero: "0") disappear from the live ConfigMap within 6 s of the template no longer rendering them, while a key someone else added and the hand-changed excluded key stay.
  • S6 "entry gone": rules was the Role's only rendered spec field and the template rendered no labels, so releasing rules left the reconciler owning nothing and the API server dropped its empty managedFields entry (the released field shows under the server's before-first-apply manager). With a rendered label the entry exists and owns only the label (measured separately). The rendered element rules[0] is not deleted, which the merge-patch enforcer and the first draft of this PR both got wrong.

S9 restarted the operator pod: the probe objects' resourceVersions did not change, all 40 objects managed on this cluster were present, and the new pod logged zero error lines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LockedResourceReconciler: a merge patch cannot remove fields the template stops rendering; enforce with server-side apply (PR #104)

1 participant