Introduce MutateAndPatchStatus helper for status writes#4942
Introduce MutateAndPatchStatus helper for status writes#4942ChrisJBurns merged 3 commits intomainfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4942 +/- ##
==========================================
- Coverage 69.00% 69.00% -0.01%
==========================================
Files 552 553 +1
Lines 72996 73016 +20
==========================================
+ Hits 50370 50383 +13
- Misses 19628 19633 +5
- Partials 2998 3000 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
ChrisJBurns
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: kubernetes-expert, go-expert-developer, code-reviewer, toolhive-expert
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Conditions-array doc claim is only true when writers Get before mutating |
8/10 | HIGH | Clarify doc |
| 2 | Comment references non-existent mcpserver_spec_patch_test.go |
10/10 | MEDIUM | Fix / remove |
| 3 | \#4767 referenced as if spec-side helper exists |
8/10 | MEDIUM | Clarify doc |
| 4 | Typed-nil obj triggers raw panic |
7/10 | MEDIUM | Document or guard |
| 5 | Stale-snapshot scalar-field clobber — doc gap | 7/10 | MEDIUM | Document contract |
| 6 | No-op mutate still PATCHes |
9/10 | LOW | One-line doc note |
| 7 | Envtest variant preferred over fake-client for merge-patch semantics | 7/10 | LOW | Follow-up test |
Overall
The helper itself is correct, small (48 lines), well-tested, and solves a real problem from #4633. The DeepCopyObject().(T) pattern, the decision to skip optimistic locking for status, the test structure (parallel, wire-body assertions, disjoint-writer regression), and the package placement are all sound. Four specialized agents reviewed the change and none disputed the fundamental design.
The findings cluster around documentation precision rather than the code behavior. The HIGH finding is the most important: the doc claim that "Conditions-array overlap stays safe via the per-condition idiom" is load-bearing on an invariant the helper does not enforce. client.MergeFrom produces RFC 7396 merge patches (confirmed in controller-runtime@v0.23.3/pkg/client/patch.go → jsonpatch.CreateMergePatch) which REPLACE arrays wholesale — the +listType=map marker on Conditions is only honored by strategic-merge-patch, which CRDs don't support. So the claim is true in practice only if every writer Gets the live object immediately before calling the helper. Worth stating that precondition explicitly so future call-site migrations don't take the doc at face value.
The two cross-references (mcpserver_spec_patch_test.go and \#4767 "spec-side rationale") point at code that does not yet exist in the repo. \#4767 is an open tracking issue, not a landed pattern, and there is no spec-patch-recording test file anywhere. These are fixable with a one-line edit each — either remove the forward references or rephrase to make clear they describe planned follow-up work.
None of these block merge in my view; a follow-up or amended commit covers them. The migration PRs that land on top of this helper will benefit from tighter documentation here.
Documentation
Per CLAUDE.md, architecture doc sync was considered. This is an internal helper with no user-facing or architecture-doc surface — docs/arch/ does not need updates. The in-package doc.go was correctly updated.
Generated with Claude Code
8034149 to
f6fab6a
Compare
f6fab6a to
9ba7b41
Compare
Relates to #4633. A shared helper that collapses the "DeepCopy → mutate → r.Status().Patch(MergeFrom(original))" idiom to a single call so remaining r.Status().Update sites can migrate without each one re-implementing the DeepCopy-before-mutate discipline by hand. Status writes deliberately use a plain merge patch, not an optimistic-lock one: the operator and the runtime reporter write disjoint status fields on every reconcile and must coexist without forcing a 409 on every overlap. Spec and metadata writes still require optimistic locking — see #4767 (tracking) / #4914 (MCPServer migration). The helper does not make every multi-writer pattern safe. The Caller contract in the doc comment spells out two footguns it cannot defend against: - JSON merge-patch replaces arrays wholesale for CRDs, so a writer to Status.Conditions must be the sole owner of the entire array. Any concurrent writer whose Patch lands between this caller's Get and Patch — on any condition type, including ones this caller does not touch — will be erased. A fresh Get narrows but does not eliminate the TOCTOU window. - A scalar re-computed from a stale snapshot that differs from the live value will overwrite a concurrent writer's update. The codified checklist for new call sites lives in .claude/rules/operator.md. Operational safeguards in the helper itself: - No-op mutations (empty merge-patch body) short-circuit before the wire call; the apiserver runs admission and audit for every PATCH regardless of body content, so steady-state reconcilers must not generate {} traffic. - A nil obj returns a descriptive error rather than panicking in the downstream type assertion. The helper lives in cmd/thv-operator/pkg/controllerutil alongside the existing controller helpers. It may move to a shared location later if a non-operator caller needs it. Pure addition — no call-site changes in this PR. Tests (cmd/thv-operator/pkg/controllerutil/status_test.go) cover: - Happy path and DeepCopy isolation. - No-op mutate skips the wire call. - Disjoint-writer preservation: with a stale snapshot, a second writer owning disjoint scalar fields survives the patch. - Stale snapshot clobbers conditions from another writer — guards the documented Caller contract so the behaviour stays load- bearing against future changes. - Stale scalar computation: re-assigning the read value is a no-op at the wire level (concurrent writer preserved); assigning a differing value overwrites live state. - Nil obj is rejected with a descriptive error, no PATCH issued. - Error propagation: apiserver failures from Status().Patch are returned unchanged for the controller's requeue decision.
9ba7b41 to
02cdcbc
Compare
Summary
r.Status().Update, which sends a fullPUT of the status stanza and zeros any field a disjoint writer owns
(e.g. a runtime reporter writing status on the same CR). Switch status writes from Update to Patch across all controllers #4633 tracks
migrating to merge-patch; this PR introduces the helper the migration
will call at every site so the DeepCopy-before-mutate discipline is
captured in one place instead of re-implemented per call.
MutateAndPatchStatus[T client.Object](ctx, c, obj, mutate)collapsesthe
DeepCopy → mutate → Status().Patch(MergeFrom(original))idiom toa single call. Plain merge patch, not optimistic-lock: status-subresource
writers with disjoint fields must coexist without forcing a 409 on every
overlap. Spec and metadata writes still require optimistic locking —
see Fix r.Update to r.Patch plus regression guard in MCPServer controller #4767 (tracking) / Patch MCPServer spec instead of Update #4914 (MCPServer migration).
comment's Caller contract spells out what it cannot defend against:
Conditions-array writers must be the sole owner of the entire array
(merge-patch replaces arrays wholesale for CRDs;
+listType=mapisonly honored by strategic-merge-patch), and a scalar re-computed from
a stale snapshot that differs from the live value will clobber a
concurrent writer.
steady-state PATCH noise on fast-requeue reconcilers, and a nil obj
returns a descriptive error instead of panicking in the
.(T)assertion..claude/rules/operator.md.the existing
r.Status().Updatecall sites will follow.Relates to #4633
Type of change
Test plan
task test)task lint-fix)Tests in
cmd/thv-operator/pkg/controllerutil/status_test.gocover:{}.scalar field; fresh
Getconfirms both the mutated field and the secondwriter's fields survive.
documented Caller contract (distinct condition types do not survive a
stale writer's merge patch, so callers must own the entire array).
no-op (concurrent writer preserved); assigning a differing value
overwrites live state.
Does this introduce a user-facing change?
No.
Generated with Claude Code