Repository navigation
Expand file tree
/
Copy pathstatus.go
More file actions
317 lines (291 loc) · 15.7 KB
/
Copy pathstatus.go
File metadata and controls
317 lines (291 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
// SPDX-License-Identifier: Apache-2.0
package controller
import (
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
// conditionValue is a condition's payload without its type: the (status, reason, message) triple
// that every gate produces and every writer consumes.
//
// It exists so a gate can RETURN its verdict instead of applying it. Passing the triple around as
// three positional strings is what made "who writes Ready?" unanswerable — every gate had to call a
// setter, so the answer was always "whoever ran last". See readiness.
type conditionValue struct {
Status metav1.ConditionStatus
Reason string
Message string
}
// conditionReasonMessage reads a condition's reason and message, substituting the given defaults
// for whichever of the two the condition leaves empty.
func conditionReasonMessage(condition *metav1.Condition, defaultReason, defaultMessage string) (string, string) {
if condition == nil {
return defaultReason, defaultMessage
}
reason, message := condition.Reason, condition.Message
if reason == "" {
reason = defaultReason
}
if message == "" {
message = defaultMessage
}
return reason, message
}
// reasonUnspecified is the placeholder for a condition written with an empty reason. The CRD schema
// requires a reason, so an empty one is rejected with a 422 that fails the whole status write and
// hides whatever the condition was trying to report. A visibly wrong reason is strictly better.
const reasonUnspecified = "Unspecified"
// reconcileStatus is the per-reconcile status session: it captures the object's status as read,
// collects every condition the reconcile writes, and commits the difference exactly once.
//
// It replaces the five near-identical `updateStatusWithRetry` implementations that each controller
// used to carry, and with them three defects those copies shared:
//
// - They wrote unconditionally. A full-object `Status().Update` on every pass bumps
// resourceVersion, which fires an Update watch event, which re-enqueues the object — every
// reconcile cost roughly two. commit() sends nothing at all when nothing changed.
// - They did read-modify-write: re-Get the object, overwrite its whole status, Update. That
// stamps the freshly-read object with an observedGeneration computed from the generation this
// reconcile actually saw, which may already be stale — kstatus then reads Current for a spec
// nobody looked at. commit() patches with optimistic concurrency instead, so a spec that moved
// under us loses the write rather than mislabelling it.
// - They retried on conflict with an exponential backoff. Re-running the whole reconcile on
// fresh data is both simpler and more correct than replaying a status computed from stale
// data; commit() records the loss and the caller requeues. See commit() for what the conflict
// actually is.
type reconcileStatus struct {
client client.Client
recorder record.EventRecorder
object client.Object
// before is the object as read at the top of the reconcile; the patch is computed against it.
before client.Object
// beforeConditions is the persisted condition set, kept for the Event on a Ready transition.
beforeConditions []metav1.Condition
// conditions points at the live status condition slice of object, which set() rewrites.
conditions *[]metav1.Condition
// persisted are the callbacks that may run only once the status write has actually landed:
// in-memory state a later reconcile reads back as "what this object published". Keeping them
// here rather than at the call sites is what makes them right for EVERY exit, including the
// early gates that commit and return, and for the two ways a write does not land — an error,
// and the optimistic lock, which is recorded rather than returned.
persisted []func()
// writeLostToRace records that commit() computed a status it could not persist because the
// object moved underneath it. The observation is discarded (publishing it would be stale), so
// what is left is an object whose PUBLISHED status is older than what this reconcile knew —
// and nothing re-enqueues it, because every For() here filters status-only updates. Callers
// ask writeLost() so they requeue promptly instead of on their converged cadence.
writeLostToRace bool
}
// writeLost reports whether the last commit() had its status write rejected by the optimistic
// lock. A reconcile whose status never landed must come back promptly whatever it computed, or the
// object keeps the older answer until its periodic requeue.
func (s *reconcileStatus) writeLost() bool { return s.writeLostToRace }
// requeueAfter is the cadence a reconcile should come back on, collapsed to
// RequeueWriteLostInterval when its status write was lost.
//
// Every controller USING THIS HELPER has the same exposure, because the hazard needs both halves:
// commit() drops the write, and the GenerationChangedPredicate on their For() filters the
// status-only update that won, so nothing re-enqueues the object — it keeps the older answer for
// its full steady interval. Fixing that at one call site would have left the same bug in the other
// four. CommitRequestReconciler carries neither half; see its Reconcile for why.
func (s *reconcileStatus) requeueAfter(cadence time.Duration) time.Duration {
if s.writeLostToRace {
return RequeueWriteLostInterval
}
return cadence
}
// statusObject is what a status session needs from the object it writes: the two fields every
// status in api/v1alpha3 carries, reachable without knowing which kind is in hand.
//
// It exists so beginStatus can stamp observedGeneration itself. That line used to sit at each of
// the five call sites, one identical copy per controller, and a controller that forgets it
// publishes a status kstatus reads as Current for a spec nobody looked at — the same failure the
// read-modify-write pattern above had. Reaching the conditions through the object rather than
// beside it falls out of the same accessor, and removes the second way to get a session wrong:
// handing it one object's conditions and another object's metadata.
//
// It carries no kind name, on purpose. conditionMetricKind resolves that from its own list,
// because CommitRequest is excluded from the condition metric and an interface anything could
// implement is not a place to keep an exclusion. See resource_condition_metrics.go.
type statusObject interface {
client.Object
// SetObservedGeneration records the generation this object's status describes.
SetObservedGeneration(generation int64)
// StatusConditions returns the condition set to write through.
StatusConditions() *[]metav1.Condition
}
// beginStatus opens the status session. Call it once, immediately after the object is read and
// before any condition is written.
//
// The generation is stamped here, AFTER before is captured and ahead of every set(): capturing the
// snapshot first is what leaves the stamp visible to the patch, so an object whose only status
// change is a new observedGeneration still gets written rather than falling into commit()'s no-op.
func beginStatus(c client.Client, recorder record.EventRecorder, object statusObject) *reconcileStatus {
before, _ := object.DeepCopyObject().(client.Object)
object.SetObservedGeneration(object.GetGeneration())
conditions := object.StatusConditions()
return &reconcileStatus{
client: c,
recorder: recorder,
object: object,
before: before,
beforeConditions: append([]metav1.Condition(nil), *conditions...),
conditions: conditions,
}
}
// set writes one condition, pinned to the generation this reconcile observed.
func (s *reconcileStatus) set(conditionType string, status metav1.ConditionStatus, reason, message string) {
if reason == "" {
reason = reasonUnspecified
}
*s.conditions = upsertCondition(
*s.conditions, conditionType, status, reason, message, s.object.GetGeneration())
}
// remove drops one condition from the set, so a report the operator no longer makes leaves no
// stale answer standing. It is the only way a condition ever disappears.
func (s *reconcileStatus) remove(conditionType string) {
apimeta.RemoveStatusCondition(s.conditions, conditionType)
}
// setValue writes one condition from a gate's verdict.
func (s *reconcileStatus) setValue(conditionType string, value conditionValue) {
s.set(conditionType, value.Status, value.Reason, value.Message)
}
// applyReadiness writes the kstatus trio — and is the ONLY thing that writes it. Every gate
// contributes to the readiness accumulator; the trio is derived from those contributions once,
// here, from a precedence stated in one place.
func (s *reconcileStatus) applyReadiness(r *readiness) {
trio := r.trio()
s.setValue(ConditionTypeReady, trio.Ready)
s.setValue(ConditionTypeReconciling, trio.Reconciling)
s.setValue(ConditionTypeStalled, trio.Stalled)
}
// commit writes the status subresource — or nothing, when the status is identical to what was read.
//
// Suppressing the no-op write is what breaks the self-triggering reconcile edge: a status write
// bumps resourceVersion and fires the Update watch event that `For()` turns straight back into a
// queued request. Nothing to say, nothing to write, no wake-up.
// afterPersist registers a callback to run once this reconcile's status write has landed.
//
// A caller that mutated status AND some in-memory record of it cannot do the second half inline:
// the patch can be refused by the optimistic lock, and then the object still holds what the winner
// wrote while the record claims otherwise. Every such record goes through here.
func (s *reconcileStatus) afterPersist(fn func()) {
s.persisted = append(s.persisted, fn)
}
// runPersisted settles the registered callbacks. It is called only where the status this reconcile
// computed is what the object now holds.
func (s *reconcileStatus) runPersisted() {
for _, fn := range s.persisted {
fn()
}
s.persisted = nil
}
func (s *reconcileStatus) commit(ctx context.Context) error {
log := logf.FromContext(ctx).WithName("status")
// Ahead of the no-op check below, and that ordering is the contract rather than a convenience.
// The condition gauge is a LEVEL, not an event: a fresh pod whose first reconcile computes no
// status change must still publish, or the object has no series at all until something happens
// to move it — the same "a restart must republish" property watch_recovery_total is a counter
// for. It is also ahead of the patch, so a write lost to the optimistic lock still publishes
// what this reconcile computed; the next pass republishes either way.
s.publishConditionMetrics()
data, err := client.MergeFrom(s.before).Data(s.object)
if err != nil {
return fmt.Errorf("compute status patch for %s: %w", client.ObjectKeyFromObject(s.object), err)
}
if string(data) == "{}" {
// Nothing to write, so what the object holds is already what this reconcile computed: the
// callbacks describe a state that is true.
s.runPersisted()
return nil
}
// Optimistic concurrency, deliberately WITHOUT a retry loop: a conflict means the object moved
// under this reconcile, so the status just computed describes a resourceVersion that is no
// longer current. Dropping the WRITE is right — publishing a stale observation is worse.
//
// The conflict is cache lag, not contention, and saying so saves the reader hunting for a
// second writer: there is exactly ONE status writer per kind (this helper, from that kind's
// reconciler) and controller-runtime never reconciles one key twice concurrently, so the loser
// is racing itself — reconcile N stores rv=2, reconcile N+1 reads rv=1 from a cache that has
// not caught up, and patches if-match rv=1.
//
// Dropping the RECONCILE along with the write is what made that a defect. Nothing re-enqueues
// the object: the winning write is status-only and every For() here carries a
// GenerationChangedPredicate to filter exactly those out. So the loss is RECORDED and the
// caller asks writeLost() before choosing its requeue — see the tests in gittarget_status_test.go
// for the behaviour this guarantees.
patch := client.MergeFromWithOptions(s.before, client.MergeFromWithOptimisticLock{})
switch err := s.client.Status().Patch(ctx, s.object, patch); {
case err == nil:
s.recordReadyTransition()
s.runPersisted()
return nil
case apierrors.IsNotFound(err):
return nil
case apierrors.IsConflict(err):
// Recorded, not returned. Returning it would turn a benign, expected race into a reconcile
// error for every controller using this helper; what the caller actually needs is to come
// back promptly, which writeLost() tells it.
s.writeLostToRace = true
log.Info("status write lost a race; the published status is stale until this object is "+
"reconciled again", "object", client.ObjectKeyFromObject(s.object))
return nil
default:
return fmt.Errorf("write status for %s: %w", client.ObjectKeyFromObject(s.object), err)
}
}
// recordReadyTransition emits one Kubernetes Event per PERSISTED change of Ready.
//
// It runs after the patch rather than beside set() on purpose: a reconcile may write Ready several
// times (an early "Validating" placeholder, then the real outcome) and only the last one is ever
// stored. Announcing intermediate values would fill `kubectl describe` with states that never
// existed. Events are how a transient failure that clears before anyone looks stays visible at all,
// and they are the only thing an Event-driven alerting pipeline can route.
//
// SEVERITY is read off Stalled, not off "Ready is not True". Those are different questions, and
// conflating them is what made the severity stop carrying information: every progressing state —
// a stream still replaying after a restart, a rule waiting on a GitTarget that is still coming up —
// announced itself as a Warning, so on a healthy cluster essentially every Warning was the system
// working. A real block then arrives looking exactly like the sixty-fourth routine one.
//
// The accumulator has already answered the question this needs: readinessProgressing means the gate
// clears on its own (kstatus InProgress) and readinessStalled means it needs a human (kstatus
// Failed). That verdict is published as Stalled, so reading it here cannot drift from the trio.
//
// An absent Stalled keeps Warning. Nothing in this package publishes Ready without the trio — every
// beginStatus caller goes through applyReadiness — but the conservative arm means a future one that
// does is loud rather than silently downgraded.
func (s *reconcileStatus) recordReadyTransition() {
if s.recorder == nil {
return
}
after := findCondition(*s.conditions, ConditionTypeReady)
if after == nil {
return
}
before := findCondition(s.beforeConditions, ConditionTypeReady)
if before != nil && before.Status == after.Status && before.Reason == after.Reason {
return
}
eventType := corev1.EventTypeNormal
if stalled := findCondition(*s.conditions, ConditionTypeStalled); stalled == nil ||
stalled.Status == metav1.ConditionTrue {
eventType = corev1.EventTypeWarning
}
s.recorder.Event(s.object, eventType, after.Reason, after.Message)
}
// metadataWritten carries metadata the reconcile wrote to the object mid-session (a finalizer, or the
// resourceVersion a status field written ahead of the commit left) into the snapshot the status patch
// is computed from: the patch is then made against the version that write left, and carries no
// metadata of its own.
func (s *reconcileStatus) metadataWritten() {
s.before.SetResourceVersion(s.object.GetResourceVersion())
s.before.SetFinalizers(s.object.GetFinalizers())
}