Skip to content

Reconciling resources

David Moreno García edited this page Aug 10, 2023 · 27 revisions

The Operator Toolkit framework uses two components to reconcile resources. The first component is the Handler, a function responsible for executing a set of operations defined by the operator developers. The second component is the Adapter, which encompass a collection of operations designed to bring a given resource to its intended state. However, before jumping into these concepts, let's talk briefly about the reconciliation loop.

The reconciliation loop

Controllers are the core of Kubernetes, and of any operator. The job of a controller is to ensure that, for any resources it is configured to watch, the actual state matches the desired one. The process of a controller taking the necessary actions to reach a desired state is called reconciliation, and it occurs in the reconciliation loop.

Each controller is responsible for managing the state of a specific resource. The controller manager runs a control loop that allows each controller to run by invoking its Reconcile() method, which looks like the following:

func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    ...
}

Given that all the actions to reach a resource's desired state are going to run within the Reconcile function, it's extremely important to reduce the amount of spaghetti code and invest in writing code that is easy to maintain.

So, what's a good way to do this? A good pattern is to have idempotent subroutines, or how they are called in this framework, Operations which is one of the pillars when writing adapters.

Writing an Adapter

Although there's nothing in Operator Toolkit defining this name, Adapter is an important term wrapping up two entities: Operations and OperationResults.

Operations

As mentioned earlier, ensuring a robust Reconcile function is crucial, and one effective approach is through the utilization of subroutines. Each subroutine is responsible for executing a specific action required by the controller upon the creation or modification of a resource. Moreover, these subroutines exhibit idempotency by examining the necessity of an action and executing it only when necessary. If a particular action has already been executed, the corresponding subroutine simply remains inert.

Within the Operator Toolkit framework, these subroutines are referred to as Operations and are defined inside the adapters. To provide a concrete example, consider the following operation:

func (a *adapter) EnsureFooIsProcessed() (reconciler.OperationResult, error) {
    if a.foo.IsProcessed() {
        return controller.ContinueProcessing()
    }
	
    ...
}

The EnsureFooIsProcessed operation in the example above shows the signature of operations:

  • Operations are exported methods attached to an Adapter.
  • No arguments are passed when called.
  • They return an OperationResult and an error.

For now, let's focus on what results are.

OperationResults

Every operation will return a single result, but there are seven results than can be used within an operation and that will affect the processing of the operations in different ways. They are:

Result Behavior
ContinueProcessing Continue processing the rest of operations defined in the Handler
Requeue Requeue the request
RequeueAfter Requeue the request after the specified delay
RequeueOnErrorOrContinue Requeue the request if something failed or continue processing the rest of operations
RequeueOnErrorOrStop Requeue the request if something failed or force the reconciliation to stop immediately, preventing the execution of the remaining operations
RequeueWithError Requeue the request with the given error
StopProcessing Force the reconciliation to stop immediately, preventing the execution of the remaining operations

Adapters

Previously, it was mentioned that the concept of an Adapter is not explicitly defined within the framework, although all operations are associated with an instance of it. Now, let's explore what these adapters actually are.

Operations do not receive any arguments, yet it is still crucial to access certain information during the resource reconciliation process. This includes the client, context, and the resource itself, among other relevant information.

To facilitate this, a new adapter is created for each request reconciliation to encapsulate and manage this information. It is the responsibility of the developer to define this adapter. One recommendation, though not mandatory, is to create an adapter.go file and utilize the following names and utility functions:

type adapter struct {
    client client.Client
    ctx    context.Context
    foo    *v1alpha1.Foo // this is the kind of resource this adapter reconciles
    logger logr.Logger
    // more fields can be added if needed
}

func newAdapter(ctx context.Context, client client.Client, foo *v1alpha1.Foo, logger logr.Logger) *adapter {
    return &adapter{
        client: client,
        ctx:    ctx,
        logger: logger,
        foo:    foo,
    }
}

Using the Handler

The ReconcileHandler in Operator Toolkit refers to a function that operates within the Reconcile method. Its primary role is to execute the Operations defined in the Adapter. This critical component acts as the final piece that brings all elements together in a cohesive manner. To further clarify its functionality, let's examine an example.

func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := c.log.WithValues("Foo", req.NamespacedName)
	
    foo := &v1alpha1.Foo{}
    err := c.client.Get(ctx, req.NamespacedName, foo)
    if err != nil {
        if errors.IsNotFound(err) {
            return ctrl.Result{}, nil
        }

        return ctrl.Result{}, err
    }

    adapter := newAdapter(ctx, c.client, foo, logger)

    return controller.ReconcileHandler([]controller.Operation{
        adapter.EnsureFinalizersAreCalled,
        adapter.EnsureFooIsValid,
        adapter.EnsureFinalizerIsAdded,
        adapter.EnsureFooIsProcessed,
    })
}

The initial part of the Reconcile method will likely ring a bell, as it mirrors the familiar boilerplate code found in typical Operator-sdk controllers. In this section, the logger is configured, and the resource managed by the Controller is retrieved.

However, the subsequent part is more interesting. Here, a fresh instance of the adapter is instantiated, ready to be employed in the call to ReconcileHandler. This function takes in a series of Operations that will be executed in the developer-specified order.

From this point onward, any additions, removals, or adjustments to the execution order of operations can be accomplished within the ReconcileHandler call. It's worth noting that the definitions of these operations will remain in the adapter file where they were initially declared.