Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,27 +238,38 @@ func NewWebInterfaceComponent(owner *MyOperatorCR) (*component.Component, error)
The controller builds the component and hands it to the framework.

```go
func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
owner := &MyOperatorCR{}
if err := r.Get(ctx, req.NamespacedName, owner); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}

comp, err := NewWebInterfaceComponent(owner)
if err != nil {
return reconcile.Result{}, err
}

return reconcile.Result{}, comp.Reconcile(ctx, component.ReconcileContext{
recCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
})
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

comp, err := NewWebInterfaceComponent(owner)
if err != nil {
return reconcile.Result{}, err
}

return reconcile.Result{}, comp.Reconcile(ctx, recCtx)
}
```

Components stage their conditions on `owner` in memory; a single deferred `component.FlushStatus` at the end of the
reconcile loop persists every condition with one `Status().Update` call. This keeps controllers with multiple components
free of self-induced 409 conflicts.

## Beyond the Basics

The Quick Start shows the common path. The sections below highlight capabilities that matter once your operator grows
Expand Down
71 changes: 64 additions & 7 deletions docs/component.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ reports their aggregate health through one condition on the owner CRD.
- [Grace Period](#grace-period)
- [Suspension Lifecycle](#suspension-lifecycle)
- [ReconcileContext](#reconcilecontext)
- [Persisting Status with FlushStatus](#persisting-status-with-flushstatus)
- [Guards](#guards)
- [Registering a Guard](#registering-a-guard)
- [Guard Behavior](#guard-behavior)
Expand Down Expand Up @@ -262,7 +263,9 @@ This means a read-only resource registered before a managed resource can extract
resource's guard or mutations.

**Phase 5: Status aggregation and condition update.** The health of each resource is collected, the grace period is
consulted, and a single aggregate condition is written to the owner object's status.
consulted, and a single aggregate condition is written to the owner object's conditions **in memory**. `Reconcile` never
calls the Kubernetes API to persist status; the controller does that in a single write at the end of its reconcile loop.
See [Persisting Status with FlushStatus](#persisting-status-with-flushstatus).

**Phase 6: Resource deletion.** Resources registered for deletion are removed from the cluster.

Expand Down Expand Up @@ -428,7 +431,7 @@ recCtx := component.ReconcileContext{
Client: r.Client, // sigs.k8s.io/controller-runtime/pkg/client
Scheme: r.Scheme, // *runtime.Scheme
Recorder: r.Recorder, // record.EventRecorder
Metrics: r.Metrics, // component.Recorder (condition metrics)
Metrics: r.Metrics, // component.Recorder (condition metrics), optional
Owner: owner, // the CRD that owns this component
}

Expand All @@ -437,9 +440,57 @@ err = comp.Reconcile(ctx, recCtx)

Dependencies are passed explicitly so components remain testable and decoupled from global state.

The `Metrics` field is required. The framework records Prometheus metrics for every condition state transition during
reconciliation. The recorder implementation is provided by
[go-crd-condition-metrics](https://github.com/sourcehawk/go-crd-condition-metrics).
The `Metrics` field is optional. When set, the framework records Prometheus metrics for every condition reported during
a reconcile. The recorder implementation is provided by
[go-crd-condition-metrics](https://github.com/sourcehawk/go-crd-condition-metrics). Leave the field `nil` to opt out of
metric recording.

## Persisting Status with FlushStatus

`Component.Reconcile` only mutates the owner's status conditions in memory. The controller is responsible for writing
those conditions to the Kubernetes API by calling `component.FlushStatus` once per reconcile, typically from a deferred
call so that conditions set on error paths are still persisted:

```go
func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
owner := &v1alpha1.MyApp{}
if err := r.Get(ctx, req.NamespacedName, owner); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}

recCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

comp, err := buildMyComponent(owner)
if err != nil {
return reconcile.Result{}, err
}
return reconcile.Result{}, comp.Reconcile(ctx, recCtx)
}
```

`FlushStatus` performs one `Status().Update` call that writes every condition currently on the owner in memory, wrapped
in `retry.RetryOnConflict`. If another writer updated the owner between the controller's initial `Get` and this call,
`FlushStatus` refetches, reapplies the conditions staged during the reconcile, and retries. Conditions managed by other
writers on the same owner are preserved because `meta.SetStatusCondition` merges by condition type.

After the update succeeds, `FlushStatus` records metrics for every condition on the owner. If `rec.Metrics` is nil,
metric recording is skipped.

This split is what allows a controller with several components (see [Keep Controllers Thin](./guidelines.md) and
[One Component Per Logical Condition](./guidelines.md)) to stage several conditions during one reconcile and persist
them all in a single write. Persisting after every component would race the components' writes against each other and
produce 409 conflicts.

## Guards

Expand All @@ -460,8 +511,14 @@ The following example shows the complete pattern. A cloud provider role resource
bucket resource uses that ARN in its spec and guards against being applied before the ARN is available:

```go
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// ...fetch owner...
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) {
// ...fetch owner and build recCtx...

defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

// roleARN is scoped to this reconcile call. The role resource's data extractor
// populates it after the role is applied. Because extraction runs per-resource
Expand Down
41 changes: 31 additions & 10 deletions docs/guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,34 +301,55 @@ clearer than splitting them into `DeploymentReady` and `ServiceReady`.

## Keep Controllers Thin

Controllers should fetch the owner, decide which components to build, and call `Reconcile()`. Business logic, resource
construction, and feature decisions belong in components and their resource builders.
Controllers should fetch the owner, decide which components to build, call `Reconcile()`, and defer a single
`component.FlushStatus` to persist status. Business logic, resource construction, and feature decisions belong in
components and their resource builders.

```go
func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
owner := &v1alpha1.MyApp{}
if err := r.Get(ctx, req.NamespacedName, owner); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}

comp, err := buildWebComponent(owner)
if err != nil {
return reconcile.Result{}, err
}

return reconcile.Result{}, comp.Reconcile(ctx, component.ReconcileContext{
recCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
})
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

comp, err := buildWebComponent(owner)
if err != nil {
return reconcile.Result{}, err
}

return reconcile.Result{}, comp.Reconcile(ctx, recCtx)
}
```

This keeps controller logic trivial to test (there is almost nothing to test) and makes component construction functions
independently testable as pure functions: owner in, component out, no cluster required.

### Flushing status is the controller's job

`Component.Reconcile` only mutates the owner's conditions in memory. Persisting them is explicitly the controller's
responsibility, via one `component.FlushStatus` call per reconcile, typically deferred so that conditions set by error
paths (for example, `fail()` in the framework) are still written when `Reconcile` returns an error.

Do not call `FlushStatus` in between component reconciles. With several components per controller the point of the split
is to stage all their conditions in memory first and write them once at the end. Flushing between components brings back
the exact 409 conflict pattern the split was introduced to eliminate.

If you do not want to emit condition metrics, leave `ReconcileContext.Metrics` as `nil`. `FlushStatus` tolerates a nil
recorder and simply skips metric emission.

## Resource Registration Order Is Execution Order

Resources are reconciled in the exact order they are registered with `WithResource()`. This is deliberate: guards and
Expand Down
12 changes: 8 additions & 4 deletions e2e/framework/cluster_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (r *ClusterE2EReconciler) Unregister(name string) {

// Reconcile implements reconcile.Reconciler. It fetches the ClusterTestApp, looks up
// the registered factory, builds the component, and reconciles it.
func (r *ClusterE2EReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
func (r *ClusterE2EReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := log.FromContext(ctx).WithValues("clustertestapp", req.NamespacedName)

owner := &ClusterTestApp{}
Expand All @@ -98,20 +98,19 @@ func (r *ClusterE2EReconciler) Reconcile(ctx context.Context, req reconcile.Requ
r.mu.RUnlock()

var comp *component.Component
var err error

switch {
case hasComp:
comp, err = compFactory(owner)
case hasRes:
res, buildErr := resFactory(owner)
resource, buildErr := resFactory(owner)
if buildErr != nil {
return reconcile.Result{}, buildErr
}
comp, err = component.NewComponentBuilder().
WithName("e2e-test").
WithConditionType("E2EReady").
WithResource(res, component.ResourceOptions{}).
WithResource(resource, component.ResourceOptions{}).
Suspend(owner.Spec.Suspended).
Build()
default:
Expand All @@ -130,6 +129,11 @@ func (r *ClusterE2EReconciler) Reconcile(ctx context.Context, req reconcile.Requ
Metrics: r.Metrics,
Owner: owner,
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

if err := comp.Reconcile(ctx, recCtx); err != nil {
return reconcile.Result{}, err
Expand Down
12 changes: 8 additions & 4 deletions e2e/framework/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (r *E2EReconciler) Unregister(key types.NamespacedName) {

// Reconcile implements reconcile.Reconciler. It fetches the TestApp, looks up
// the registered factory, builds the component, and reconciles it.
func (r *E2EReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
func (r *E2EReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := log.FromContext(ctx).WithValues("testapp", req.NamespacedName)

owner := &TestApp{}
Expand All @@ -98,20 +98,19 @@ func (r *E2EReconciler) Reconcile(ctx context.Context, req reconcile.Request) (r
r.mu.RUnlock()

var comp *component.Component
var err error

switch {
case hasComp:
comp, err = compFactory(owner)
case hasRes:
res, buildErr := resFactory(owner)
resource, buildErr := resFactory(owner)
if buildErr != nil {
return reconcile.Result{}, buildErr
}
comp, err = component.NewComponentBuilder().
WithName("e2e-test").
WithConditionType("E2EReady").
WithResource(res, component.ResourceOptions{}).
WithResource(resource, component.ResourceOptions{}).
Suspend(owner.Spec.Suspended).
Build()
default:
Expand All @@ -130,6 +129,11 @@ func (r *E2EReconciler) Reconcile(ctx context.Context, req reconcile.Request) (r
Metrics: r.Metrics,
Owner: owner,
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

if err := comp.Reconcile(ctx, recCtx); err != nil {
return reconcile.Result{}, err
Expand Down
13 changes: 12 additions & 1 deletion examples/component-prerequisites/app/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,25 @@ type Controller struct {
}

// Reconcile builds and reconciles the infra and app components in order.
func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error {
//
// Both components share the same ReconcileContext and stage their conditions
// on the owner in memory; a single deferred FlushStatus at the end of
// reconciliation persists both conditions in one API call. That is what
// prevents the sequential components from racing two separate status updates
// against the same owner and hitting conflicts.
func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err error) {
recCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

// --- Infra component: no prerequisites ---
cmResource, err := r.NewConfigMapResource(owner)
Expand Down
23 changes: 15 additions & 8 deletions examples/custom-resource/app/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,20 @@ type Controller struct {
}

// Reconcile builds and reconciles a single component managing the certificate.
func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error {
func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err error) {
recCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()

certResource, err := r.NewCertificateResource(owner)
if err != nil {
return err
Expand All @@ -38,11 +51,5 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) error {
return err
}

return comp.Reconcile(ctx, component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
})
return comp.Reconcile(ctx, recCtx)
}
Loading
Loading