Skip to content

Commit 85f47bf

Browse files
authored
Add status updater (#31)
* add ReasonableError type * add StatusUpdater * change UpdateStatus signature to return reconcile result * change WithoutField to WithoutFields * make StatusUpdater work with condition implementations that are not pointers * add more tests for status updater and fix bugs * library improvements (from implementing Landscape controller of Gardener ClusterProvider) * add documentation for status updater * add documentation for errors package * downgrade task version to avoid regression
1 parent dc05a1e commit 85f47bf

File tree

15 files changed

+1548
-107
lines changed

15 files changed

+1548
-107
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: Install Task
2727
uses: arduino/setup-task@v2
2828
with:
29-
version: 3.x
29+
version: 3.42.1
3030

3131
- name: task generate
3232
run: |

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: Install Task
2525
uses: arduino/setup-task@v2
2626
with:
27-
version: 3.x
27+
version: 3.42.1
2828

2929
- name: Read and validate VERSION
3030
id: version

README.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,161 @@ The `pkg/controller` package contains useful functions for setting up and runnin
103103
#### Noteworthy Functions
104104

105105
- `LoadKubeconfig` creates a REST config for accessing a k8s cluster. It can be used with a path to a kubeconfig file, or a directory containing files for a trust relationship. When called with an empty path, it returns the in-cluster configuration.
106+
- See also the [`clusters`](#clusters) package, which uses this function internally, but provides some further tooling around it.
106107
- There are some functions useful for working with annotations and labels, e.g. `HasAnnotationWithValue` or `EnsureLabel`.
107108
- There are multiple predefined predicates to help with filtering reconciliation triggers in controllers, e.g. `HasAnnotationPredicate` or `DeletionTimestampChangedPredicate`.
108109
- The `K8sNameHash` function can be used to create a hash that can be used as a name for k8s resources.
109110

111+
#### Status Updater
112+
113+
The status updater gets its own section, because it requires a slightly longer explanation. The idea of it is that many of our resources use a status similar to this:
114+
```go
115+
type MyStatus struct {
116+
// ObservedGeneration is the generation of this resource that was last reconciled by the controller.
117+
ObservedGeneration int64 `json:"observedGeneration"`
118+
119+
// LastReconcileTime is the time when the resource was last reconciled by the controller.
120+
LastReconcileTime metav1.Time `json:"lastReconcileTime"`
121+
122+
// Phase is the overall phase of the resource.
123+
Phase string
124+
125+
// Reason is expected to contain a CamelCased string that provides further information in a machine-readable format.
126+
// +optional
127+
Reason string `json:"reason,omitempty"`
128+
129+
// Message contains further details in a human-readable format.
130+
// +optional
131+
Message string `json:"message,omitempty"`
132+
133+
// Conditions contains the conditions.
134+
// +optional
135+
Conditions []MyCondition `json:"conditions,omitempty"`
136+
}
137+
```
138+
139+
The logic for most of these fields is very similar across all of our controllers: `ObservedGeneration` and `LastReconcileTime` should always be updated, `Phase` is usually computed based on the conditions or on whether an error occurred, `Reason`, `Message` and `Conditions` are generated during reconciliation.
140+
141+
To reduce redundant coding and ensure a similar behavior in all controllers, the _status updater_ can be used to update the status. A full example could look something like this:
142+
```go
143+
import (
144+
ctrlutils "github.com/openmcp-project/controller-utils/pkg/controller"
145+
v1alpha1 // resource API package
146+
)
147+
148+
// optional, using a type alias removes the need to specify the type arguments every time
149+
type ReconcileResult = ctrlutils.ReconcileResult[*v1alpha1.MyResource, v1alpha1.ConditionStatus]
150+
151+
// this is the method called by the controller-runtime
152+
func (r *GardenerMyResourceReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
153+
rr := r.reconcile(ctx, req)
154+
// status update
155+
return ctrlutils.NewStatusUpdaterBuilder[*v1alpha1.MyResource, v1alpha1.MyResourcePhase, v1alpha1.ConditionStatus]().
156+
WithPhaseUpdateFunc(func(obj *v1alpha1.MyResource, rr ctrlutils.ReconcileResult[*v1alpha1.MyResource, v1alpha1.ConditionStatus]) (v1alpha1.MyResourcePhase, error) {
157+
if rr.ReconcileError != nil {
158+
return v1alpha1.PROVIDER_CONFIG_PHASE_FAILED, nil
159+
}
160+
if len(rr.Conditions) > 0 {
161+
for _, con := range rr.Conditions {
162+
if con.GetStatus() != v1alpha1.CONDITION_TRUE {
163+
return v1alpha1.PROVIDER_CONFIG_PHASE_FAILED, nil
164+
}
165+
}
166+
}
167+
return v1alpha1.PROVIDER_CONFIG_PHASE_SUCCEEDED, nil
168+
}).
169+
WithConditionUpdater(func() conditions.Condition[v1alpha1.ConditionStatus] {
170+
return &v1alpha1.Condition{}
171+
}, true).
172+
Build().
173+
UpdateStatus(ctx, r.PlatformCluster.Client(), rr)
174+
}
175+
176+
func (r *GardenerProviderConfigReconciler) reconcile(ctx context.Context, req reconcile.Request) ReconcileResult {
177+
// actual reconcile logic here
178+
}
179+
```
180+
181+
Some information regarding the example:
182+
- `v1alpha1.MyResource` is the resource type being reconciled in this example.
183+
- `v1alpha1.MyResourcePhase` is the type of the `Phase` field used in the status of `MyResource`.
184+
- It must be a string-like type, e.g. `type MyResourcePhase string`.
185+
- If the resource status doesn't have a `Phase` or updating it is not desired, simply set this type argument to `string`.
186+
- `v1alpha1.ConditionStatus` is the type of the `Status` field within the conditions. It must be `comparable`.
187+
- Usually, this will either be a boolean or a string-like type.
188+
- If the resource status doesn't have conditions or updating them is not desired, simply set this type argument to `bool`.
189+
- The conditions must be a list of a type `T`, where either `T` or `*T` implements the `conditions.Condition[ConType]` interface.
190+
- `ConType` is `v1alpha1.ConditionStatus` in this example.
191+
192+
193+
**How to use the status updater**
194+
195+
It is recommended to move the actual reconciliation logic into a helper function (`reconcile` in the example). This makes it easier to ensure that the status updater is always called, no matter where the reconciliation exits, e.g. due to an error. This helper function should then return the `ReconcileResult` required by the status updater.
196+
197+
First, initialize a new `StatusUpdaterBuilder`:
198+
```go
199+
ctrlutils.NewStatusUpdaterBuilder[*v1alpha1.MyResource, v1alpha1.MyResourcePhase, v1alpha1.ConditionStatus]()
200+
```
201+
It takes the type of the reconciled resource, the type of its `Phase` attribute and the type of the `Status` attribute of its conditions as type arguments.
202+
203+
If you want to update the phase, you have to pass in a function that computes the new phase based on the the current state of the object and the returned reconcile result. Note that the function just has to return the phase, not to set it in the object. Failing to provide this function causes the updater to use a dummy implementation that sets the phase to the empty string.
204+
```go
205+
WithPhaseUpdateFunc(func(obj *v1alpha1.MyResource, rr ctrlutils.ReconcileResult[*v1alpha1.MyResource, v1alpha1.ConditionStatus]) (v1alpha1.MyResourcePhase, error) {
206+
if rr.ReconcileError != nil {
207+
return v1alpha1.PROVIDER_CONFIG_PHASE_FAILED, nil
208+
}
209+
if len(rr.Conditions) > 0 {
210+
for _, con := range rr.Conditions {
211+
if con.GetStatus() != v1alpha1.CONDITION_TRUE {
212+
return v1alpha1.PROVIDER_CONFIG_PHASE_FAILED, nil
213+
}
214+
}
215+
}
216+
return v1alpha1.PROVIDER_CONFIG_PHASE_SUCCEEDED, nil
217+
})
218+
```
219+
220+
If the conditions should be updated, the `WithConditionUpdater` method must be called. Similarly to the condition updater from the `conditions` package - which is used internally - it requires a constructor function that returns a new, empty instance of the controller-specific `conditions.Condition` implementation. The second argument specifies whether existing conditions that are not part of the updated conditions in the `ReconcileResult` should be removed or kept.
221+
222+
You can then `Build()` the status updater and run `UpdateStatus()` to do the actual status update. The return values of this method are meant to be returned by the `Reconcile` function.
223+
224+
**Some more details**
225+
226+
- The status updater uses reflection to modifiy the status' fields. This requires it to know the field names (the ones in go, not the ones in the YAML representation). By default, it expects them to be `Status` for the status itself and `Phase`, `ObservedGeneration`, `LastReconcileTime`, `Reason`, `Message`, and `Conditions` for the respective fields within the status.
227+
- To use a different field name, overwrite it by using either `WithFieldOverride` or `WithFieldOverrides`.
228+
- If any of the fields is not contained top-level in the status but within a nested struct, the names of these fields must be prefixed with the names of the corresponding structs, separated by a `.`. The `WithNestedStruct` method can be used to set such a prefix quickly for one or more fields.
229+
- To disable the update of a specific field altogether, set its name to the empty string. This can be done via the aforementioned `WithFieldOverride`/`WithFieldOverrides` methods, or simpler via `WithoutFields`.
230+
- Doing this for the status field itself turns the status update into a no-op.
231+
- 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_`.
232+
- The `AllStatusFields()` function returns a list containing all status field keys, _except the one for the status field itself_, for convenience.
233+
- 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.
234+
235+
**The ReconcileResult**
236+
237+
The `ReconcileResult` that is passed into the status updater is expected to contain a representation of what happened during the reconciliation. Its fields influence what the updated status will look like.
238+
239+
- `Result` contains the `reconcile.Result` that is expected as a return value from the `reconcile.Reconciler` interface's `Reconcile` method. It is not modified in any way and simply passed through. It does not affect any of the status' fields.
240+
- `ReconcileError` contains any error(s) that occurred during the actual reconciliation. It must be of type `errors.ReasonableError`. This will also be the second return argument from the `UpdateStatus()` method.
241+
- `Reason` and `Message` can be set to set the status' corresponding fields.
242+
- If either one is nil, but `ReconcileError` is not, it will be filled with a value derived from the error.
243+
- `Conditions` contains the updated conditions. Depending on with which arguments `WithConditionUpdater` was called, the existing conditions will be either updated with these ones (keeping the other ones), or be replaced by them.
244+
- `Object` contains the object to be updated.
245+
- If `Object` is nil, no status update will be performed.
246+
- `OldObject` holds the version of the object that will be used as a base for constructing the patch during the status update.
247+
- If this is nil, `Object` will be used instead.
248+
- If this is non-nil, it must not point to the same instance as `Object` - use the `DeepCopy()` function to create a different instance.
249+
- All changes to `Object`'s status that are not part to `OldObject`'s status will be included in the patch during the status update. This can be used to inject custom changes to the status into the status update (in addition to the `WithCustomUpdateFunc` mentioned above).
250+
251+
### errors
252+
253+
The `errors` package contains the `ReasonableError` type, which combines a normal `error` with a reason string. This is useful for errors that happen during reconciliation for updating the resource's status with a reason for the error later on.
254+
255+
#### Noteworthy Functions:
256+
257+
- `WithReason(...)` can be used to wrap a standard error together with a reason into a `ReasonableError`.
258+
- `Errorf(...)` can be used to wrap an existing `ReasonableError` together with a new error, similarly to how `fmt.Errorf(...)` does it for standard errors.
259+
- `NewReasonableErrorList(...)` or `Join(...)` can be used to work with lists of errors. `Aggregate()` turns them into a single error again.
260+
110261
### logging
111262

112263
This package contains the logging library from the [Landscaper controller-utils module](https://github.com/gardener/landscaper/tree/master/controller-utils/pkg/logging).

pkg/clusters/cluster.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ func (c *Cluster) WithConfigPath(cfgPath string) *Cluster {
4040
return c
4141
}
4242

43+
// WithRestConfig allows to set the REST config manually.
44+
// Returns the cluster for chaining.
45+
func (c *Cluster) WithRESTConfig(cfg *rest.Config) *Cluster {
46+
c.restCfg = cfg
47+
return c
48+
}
49+
4350
// RegisterConfigPathFlag adds a flag '--<id>-cluster' for the cluster's config path to the given flag set.
4451
// Panics if the cluster's id is not set.
4552
func (c *Cluster) RegisterConfigPathFlag(flags *flag.FlagSet) {
@@ -117,7 +124,7 @@ func (c *Cluster) InitializeClient(scheme *runtime.Scheme) error {
117124
if err != nil {
118125
return fmt.Errorf("failed to create '%s' cluster client: %w", c.ID(), err)
119126
}
120-
clu, err := cluster.New(c.restCfg, func(o *cluster.Options) { o.Scheme = scheme })
127+
clu, err := cluster.New(c.restCfg, func(o *cluster.Options) { o.Scheme = scheme; o.Cache.Scheme = scheme })
121128
if err != nil {
122129
return fmt.Errorf("failed to create '%s' cluster Cluster representation: %w", c.ID(), err)
123130
}

pkg/conditions/updater.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import (
1111
// conditionUpdater is a helper struct for updating a list of Conditions.
1212
// Use the ConditionUpdater constructor for initializing.
1313
type conditionUpdater[T comparable] struct {
14-
Now time.Time
15-
conditions map[string]Condition[T]
16-
updated sets.Set[string]
17-
constructor func() Condition[T]
18-
changed bool
14+
Now time.Time
15+
conditions map[string]Condition[T]
16+
updated sets.Set[string]
17+
construct func() Condition[T]
18+
changed bool
1919
}
2020

2121
// ConditionUpdater creates a builder-like helper struct for updating a list of Conditions.
@@ -30,12 +30,12 @@ type conditionUpdater[T comparable] struct {
3030
//
3131
// Usage example:
3232
// status.conditions = ConditionUpdater(status.conditions, true).UpdateCondition(...).UpdateCondition(...).Conditions()
33-
func ConditionUpdater[T comparable](constructor func() Condition[T], conditions []Condition[T], removeUntouched bool) *conditionUpdater[T] {
33+
func ConditionUpdater[T comparable](construct func() Condition[T], conditions []Condition[T], removeUntouched bool) *conditionUpdater[T] {
3434
res := &conditionUpdater[T]{
35-
Now: time.Now(),
36-
conditions: make(map[string]Condition[T], len(conditions)),
37-
constructor: constructor,
38-
changed: false,
35+
Now: time.Now(),
36+
conditions: make(map[string]Condition[T], len(conditions)),
37+
construct: construct,
38+
changed: false,
3939
}
4040
for _, con := range conditions {
4141
res.conditions[con.GetType()] = con
@@ -50,7 +50,7 @@ func ConditionUpdater[T comparable](constructor func() Condition[T], conditions
5050
// 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.
5151
// Returns the receiver for easy chaining.
5252
func (c *conditionUpdater[T]) UpdateCondition(conType string, status T, reason, message string) *conditionUpdater[T] {
53-
con := c.constructor()
53+
con := c.construct()
5454
con.SetType(conType)
5555
con.SetStatus(status)
5656
con.SetReason(reason)
@@ -103,6 +103,7 @@ func (c *conditionUpdater[T]) RemoveCondition(conType string) *conditionUpdater[
103103
// If the condition updater was initialized with removeUntouched=true, this list will only contain the conditions which have been updated
104104
// in between the condition updater creation and this method call. Otherwise, it will potentially also contain old conditions.
105105
// The conditions are returned sorted by their type.
106+
// The second return value indicates whether the condition list has actually changed.
106107
func (c *conditionUpdater[T]) Conditions() ([]Condition[T], bool) {
107108
res := make([]Condition[T], 0, len(c.conditions))
108109
for _, con := range c.conditions {

0 commit comments

Comments
 (0)