Skip to content

Commit 2fdff12

Browse files
authored
feat: event recording for changed conditions (#87)
* enable event recording for changed conditions * add tests for condition event recording * adapt documentation * task generate
1 parent 3a5387f commit 2fdff12

File tree

4 files changed

+409
-35
lines changed

4 files changed

+409
-35
lines changed

docs/libs/status.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,26 @@ For simplicity, all commands can be chained:
2929
updatedCons, changed := conditions.ConditionUpdater(oldCons, false).UpdateCondition("myCondition", conditions.FromBool(true), myObj.Generation, "newReason", "newMessage").Conditions()
3030
```
3131

32+
### Event Recording for Conditions
33+
34+
The condition updater can optionally record events for changed conditions. To enable event recording, call first `WithEventRecorder` and later `Record` on the `ConditionUpdater`:
35+
```go
36+
updatedCons, changed := conditions.ConditionUpdater(...).WithEventRecorder(recorder, conditions.EventIfChanged).UpdateCondition(...).Record(myObj).Conditions()
37+
```
38+
Note that `Record` records all changes (`UpdateCondition` and `RemoveCondition` calls) that happened between construction of the condition updater and the `Record` call. Changes that are done after the `Record` call will not result in events. It is therefore recommended to call this method only after all conditions have been updated. Calling `Record` multiple times will lead to duplicate events.
39+
40+
`Record` is a no-op, if either the event recorder is `nil` (most likely because `WithEventRecorder` has not been called before) or the given object is `nil`.
41+
42+
The `WithEventRecorder` method takes a verbosity as second argument. There are three known verbosity values, each of which is stored in a corresponding constant in the `conditions` package:
43+
- `perChange` (constant: `EventPerChange`)
44+
- This is the most verbose option which creates a single event for each condition that was added, removed, or changed its status. It also displays the new and/or previous status of the condition, if applicable.
45+
- `perNewStatus` (constant: `EventPerNewStatus`)
46+
- This verbosity bundles all changes to the same status in a single event. This means that it will at most record four events per conditions update: one for all conditions that became `True`, one for all that became `False`, one for all that became `Unknown`, and one for all conditions that were removed. The condition types are listed in the events, their respective previous status is not.
47+
- `ifChanged` (constant: `EventIfChanged`)
48+
- This is the least verbose option. It will always log only a single event that bundles all changes. The condition types are listed, but the event does not allow to differentiate between added, removed, or changed conditions and does not contain any information about any condition's previous or current status.
49+
50+
Setting the verbosity to any other than these values results in no events being recorded.
51+
3252
## Status Updater
3353

3454
The status updater is based on the idea that many of our resources use a status similar to this:
@@ -140,6 +160,7 @@ You can then `Build()` the status updater and run `UpdateStatus()` to do the act
140160
- The package contains constants with the field keys that are required by most of these methods. `STATUS_FIELD` refers to the `Status` field itself, the other field keys are prefixed with `STATUS_FIELD_`.
141161
- The `AllStatusFields()` function returns a list containing all status field keys, _except the one for the status field itself_, for convenience.
142162
- The `WithCustomUpdateFunc` method can be used to inject a function that performs custom logic on the resource's status. Note that while the function gets the complete object as an argument, only changes to its status will be updated by the status updater.
163+
- `WithConditionEvents` can be used to enable event recording for changed conditions. The events are automatically connected to the resource from the `ReconcileResult`'s `Object` field, no events will be recorded if that field is `nil`.
143164

144165
### The ReconcileResult
145166

pkg/conditions/updater.go

Lines changed: 169 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,50 @@
11
package conditions
22

33
import (
4+
"reflect"
45
"slices"
56
"strings"
67

8+
corev1 "k8s.io/api/core/v1"
79
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/runtime"
811
"k8s.io/apimachinery/pkg/util/sets"
12+
"k8s.io/client-go/tools/record"
13+
14+
"github.com/openmcp-project/controller-utils/pkg/collections"
15+
)
16+
17+
const EventReasonConditionChanged = "ConditionChanged"
18+
19+
type EventVerbosity string
20+
21+
const (
22+
// EventPerChange causes one event to be recorded for each condition that has changed.
23+
// This is the most verbose setting. The old and new status of each changed condition will be visible in the event message.
24+
EventPerChange EventVerbosity = "perChange"
25+
// EventPerNewStatus causes one event to be recorded for each new status that any condition has reached.
26+
// This means that at max four events will be recorded:
27+
// - the following conditions changed to True (including newly added conditions)
28+
// - the following conditions changed to False (including newly added conditions)
29+
// - the following conditions changed to Unknown (including newly added conditions)
30+
// - the following conditions were removed
31+
// The old status of the conditions will not be part of the event message.
32+
EventPerNewStatus EventVerbosity = "perNewStatus"
33+
// EventIfChanged causes a single event to be recorded if any condition's status has changed.
34+
// All changed conditions will be listed, but not their old or new status.
35+
EventIfChanged EventVerbosity = "ifChanged"
936
)
1037

1138
// conditionUpdater is a helper struct for updating a list of Conditions.
1239
// Use the ConditionUpdater constructor for initializing.
1340
type conditionUpdater struct {
14-
Now metav1.Time
15-
conditions map[string]metav1.Condition
16-
updated sets.Set[string]
17-
changed bool
41+
Now metav1.Time
42+
conditions map[string]metav1.Condition
43+
original map[string]metav1.Condition
44+
eventRecoder record.EventRecorder
45+
eventVerbosity EventVerbosity
46+
updates map[string]metav1.ConditionStatus
47+
removeUntouched bool
1848
}
1949

2050
// ConditionUpdater creates a builder-like helper struct for updating a list of Conditions.
@@ -31,19 +61,29 @@ type conditionUpdater struct {
3161
// status.conditions = ConditionUpdater(status.conditions, true).UpdateCondition(...).UpdateCondition(...).Conditions()
3262
func ConditionUpdater(conditions []metav1.Condition, removeUntouched bool) *conditionUpdater {
3363
res := &conditionUpdater{
34-
Now: metav1.Now(),
35-
conditions: make(map[string]metav1.Condition, len(conditions)),
36-
changed: false,
64+
Now: metav1.Now(),
65+
conditions: make(map[string]metav1.Condition, len(conditions)),
66+
updates: make(map[string]metav1.ConditionStatus),
67+
removeUntouched: removeUntouched,
68+
original: make(map[string]metav1.Condition, len(conditions)),
3769
}
3870
for _, con := range conditions {
3971
res.conditions[con.Type] = con
40-
}
41-
if removeUntouched {
42-
res.updated = sets.New[string]()
72+
res.original[con.Type] = con
4373
}
4474
return res
4575
}
4676

77+
// WithEventRecorder enables event recording for condition changes.
78+
// Note that this method must be called before any UpdateCondition calls, otherwise the events for the conditions will not be recorded.
79+
// The verbosity argument controls how many events are recorded and what information they contain.
80+
// If the event recorder is nil, no events will be recorded.
81+
func (c *conditionUpdater) WithEventRecorder(recorder record.EventRecorder, verbosity EventVerbosity) *conditionUpdater {
82+
c.eventRecoder = recorder
83+
c.eventVerbosity = verbosity
84+
return c
85+
}
86+
4787
// UpdateCondition updates or creates the condition with the specified type.
4888
// All fields of the condition are updated with the values given in the arguments, but the condition's LastTransitionTime is only updated (with the timestamp contained in the receiver struct) if the status changed.
4989
// Returns the receiver for easy chaining.
@@ -61,17 +101,8 @@ func (c *conditionUpdater) UpdateCondition(conType string, status metav1.Conditi
61101
// update LastTransitionTime only if status changed
62102
con.LastTransitionTime = old.LastTransitionTime
63103
}
64-
if !c.changed {
65-
if ok {
66-
c.changed = old.Status != con.Status || old.Reason != con.Reason || old.Message != con.Message
67-
} else {
68-
c.changed = true
69-
}
70-
}
104+
c.updates[conType] = status
71105
c.conditions[conType] = con
72-
if c.updated != nil {
73-
c.updated.Insert(conType)
74-
}
75106
return c
76107
}
77108

@@ -83,7 +114,8 @@ func (c *conditionUpdater) UpdateConditionFromTemplate(con metav1.Condition) *co
83114
// HasCondition returns true if a condition with the given type exists in the updated condition list.
84115
func (c *conditionUpdater) HasCondition(conType string) bool {
85116
_, ok := c.conditions[conType]
86-
return ok && (c.updated == nil || c.updated.Has(conType))
117+
_, updated := c.updates[conType]
118+
return ok && (!c.removeUntouched || updated)
87119
}
88120

89121
// RemoveCondition removes the condition with the given type from the updated condition list.
@@ -92,10 +124,7 @@ func (c *conditionUpdater) RemoveCondition(conType string) *conditionUpdater {
92124
return c
93125
}
94126
delete(c.conditions, conType)
95-
if c.updated != nil {
96-
c.updated.Delete(conType)
97-
}
98-
c.changed = true
127+
delete(c.updates, conType)
99128
return c
100129
}
101130

@@ -105,20 +134,126 @@ func (c *conditionUpdater) RemoveCondition(conType string) *conditionUpdater {
105134
// The conditions are returned sorted by their type.
106135
// The second return value indicates whether the condition list has actually changed.
107136
func (c *conditionUpdater) Conditions() ([]metav1.Condition, bool) {
137+
res := c.updatedConditions()
138+
slices.SortStableFunc(res, func(a, b metav1.Condition) int {
139+
return strings.Compare(a.Type, b.Type)
140+
})
141+
return res, c.changed(res)
142+
}
143+
144+
func (c *conditionUpdater) updatedConditions() []metav1.Condition {
108145
res := make([]metav1.Condition, 0, len(c.conditions))
109146
for _, con := range c.conditions {
110-
if c.updated == nil {
147+
if _, updated := c.updates[con.Type]; updated || !c.removeUntouched {
111148
res = append(res, con)
112-
continue
113149
}
114-
if c.updated.Has(con.Type) {
115-
res = append(res, con)
116-
} else {
117-
c.changed = true
150+
}
151+
return res
152+
}
153+
154+
func (c *conditionUpdater) changed(newCons []metav1.Condition) bool {
155+
if len(c.original) != len(newCons) {
156+
return true
157+
}
158+
for _, newCon := range newCons {
159+
oldCon, found := c.original[newCon.Type]
160+
if !found || !reflect.DeepEqual(newCon, oldCon) {
161+
return true
118162
}
119163
}
120-
slices.SortStableFunc(res, func(a, b metav1.Condition) int {
121-
return strings.Compare(a.Type, b.Type)
164+
return false
165+
}
166+
167+
// Record records events for the updated conditions on the given object.
168+
// Which events are recorded depends on the eventVerbosity setting.
169+
// In any setting, events are only recorded for conditions that have somehow changed.
170+
// This is a no-op if either the event recorder or the given object is nil.
171+
// Note that events will be duplicated if this method is called multiple times.
172+
// Returns the receiver for easy chaining.
173+
func (c *conditionUpdater) Record(obj runtime.Object) *conditionUpdater {
174+
if c.eventRecoder == nil || obj == nil {
175+
return c
176+
}
177+
178+
updatedCons := c.updatedConditions()
179+
if !c.changed(updatedCons) {
180+
// nothing to do if there are no changes
181+
return c
182+
}
183+
lostCons := collections.ProjectMapToMap(c.original, func(conType string, con metav1.Condition) (string, metav1.ConditionStatus) {
184+
return conType, con.Status
122185
})
123-
return res, c.changed
186+
for _, con := range updatedCons {
187+
delete(lostCons, con.Type)
188+
}
189+
190+
switch c.eventVerbosity {
191+
case EventPerChange:
192+
for _, con := range updatedCons {
193+
oldCon, found := c.original[con.Type]
194+
if !found {
195+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "Condition '%s' added with status '%s'", con.Type, con.Status)
196+
continue
197+
}
198+
if con.Status != oldCon.Status {
199+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "Condition '%s' changed from '%s' to '%s'", con.Type, oldCon.Status, con.Status)
200+
continue
201+
}
202+
}
203+
for conType, oldStatus := range lostCons {
204+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "Condition '%s' with status '%s' removed", conType, oldStatus)
205+
}
206+
207+
case EventPerNewStatus:
208+
trueCons := sets.New[string]()
209+
falseCons := sets.New[string]()
210+
unknownCons := sets.New[string]()
211+
212+
for _, con := range updatedCons {
213+
// only add conditions that have changed
214+
if oldCon, found := c.original[con.Type]; found && con.Status == oldCon.Status {
215+
continue
216+
}
217+
switch con.Status {
218+
case metav1.ConditionTrue:
219+
trueCons.Insert(con.Type)
220+
case metav1.ConditionFalse:
221+
falseCons.Insert(con.Type)
222+
case metav1.ConditionUnknown:
223+
unknownCons.Insert(con.Type)
224+
}
225+
}
226+
227+
if trueCons.Len() > 0 {
228+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "The following conditions changed to 'True': %s", strings.Join(sets.List(trueCons), ", "))
229+
}
230+
if falseCons.Len() > 0 {
231+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "The following conditions changed to 'False': %s", strings.Join(sets.List(falseCons), ", "))
232+
}
233+
if unknownCons.Len() > 0 {
234+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "The following conditions changed to 'Unknown': %s", strings.Join(sets.List(unknownCons), ", "))
235+
}
236+
if len(lostCons) > 0 {
237+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "The following conditions were removed: %s", strings.Join(sets.List(sets.KeySet(lostCons)), ", "))
238+
}
239+
240+
case EventIfChanged:
241+
changedCons := sets.New[string]()
242+
for _, con := range updatedCons {
243+
if oldCon, found := c.original[con.Type]; !found || con.Status != oldCon.Status {
244+
changedCons.Insert(con.Type)
245+
}
246+
}
247+
for conType := range lostCons {
248+
changedCons.Insert(conType)
249+
}
250+
if changedCons.Len() > 0 {
251+
c.eventRecoder.Eventf(obj, corev1.EventTypeNormal, EventReasonConditionChanged, "The following conditions have changed: %s", strings.Join(sets.List(changedCons), ", "))
252+
}
253+
}
254+
255+
ns := &corev1.Namespace{}
256+
ns.GetObjectKind()
257+
258+
return c
124259
}

0 commit comments

Comments
 (0)