Skip to main content

Controllers

Controllers are the heart of your Kubernetes operator. They implement the reconciliation logic that ensures your custom resources are in the desired state.

Creating a Controller

To create a controller, create a class that implements IEntityController<TEntity> where TEntity is your custom entity type:

public class V1DemoEntityController(
ILogger<V1DemoEntityController> logger,
IKubernetesClient client) : IEntityController<V1DemoEntity>
{
public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
logger.LogInformation("Reconciling entity {Entity}.", entity);
// Implement your reconciliation logic here
return ReconciliationResult<V1DemoEntity>.Success(entity);
}

public async Task<ReconciliationResult<V1DemoEntity>> DeletedAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
logger.LogInformation("Deleting entity {Entity}.", entity);
// Implement your cleanup logic here
return ReconciliationResult<V1DemoEntity>.Success(entity);
}
}

Resource Watcher

When you create a controller, KubeOps automatically creates a resource watcher (informer) for your entity type. This watcher:

  • Monitors the Kubernetes API for changes to your custom resources
  • Triggers reconciliation when resources are added, modified, or deleted
  • Maintains a local cache of resources to reduce API server load

The Event Pipeline

When a watch event arrives from the Kubernetes API server, KubeOps routes it through a fixed pipeline before your controller code runs:

ComponentResponsibility
ResourceWatcherReceives raw watch events, skips events without a generation change, enqueues the rest
TimedEntityQueueHolds entries until their scheduled time; supports immediate and delayed dispatch
EntityQueueBackgroundServiceControls parallelism via a global semaphore and per-UID locks before handing off to the reconciler
ReconcilerResolves the controller, manages finalizer attach/detach, calls either ReconcileAsync/DeletedAsync on the controller or FinalizeAsync on a registered finalizer
Your ControllerImplements the business logic for desired-state reconciliation
Your FinalizerRuns cleanup logic before a resource is deleted; invoked instead of the controller when finalization is required

The requeueAfter path (dashed) is triggered when your controller returns a ReconciliationResult with a non-zero RequeueAfter value.

KubeOps creates one such pipeline — watcher, queue, and background service — per registered controller. Multiple controllers for the same entity type (for example with different label selectors) therefore run fully independent pipelines that never suppress each other's events — see Multiple Controllers per Entity. With the opt-in WatchStrategy.SharedPerEntity, a single shared watcher per entity type replaces the per-controller watchers and dispatches each event to all pipelines whose label selectors match — see Watch Strategy for the trade-offs.

Reconciliation Loop

The reconciliation loop is the core of your operator's functionality. It consists of two main methods:

ReconcileAsync

This method is called when:

  • A new resource is created
  • An existing resource is modified
  • The operator starts up and discovers existing resources
public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
// Check if required resources exist
var deployment = await client.GetAsync<V1Deployment>(
entity.Spec.DeploymentName,
entity.Namespace(),
cancellationToken);

if (deployment == null)
{
// Create the deployment if it doesn't exist
await client.CreateAsync(
new V1Deployment
{
Metadata = new V1ObjectMeta
{
Name = entity.Spec.DeploymentName,
NamespaceProperty = entity.Namespace()
},
Spec = new V1DeploymentSpec
{
Replicas = entity.Spec.Replicas,
// ... other deployment configuration
}
},
cancellationToken);
}

// Update status to reflect current state
entity.Status.LastReconciled = DateTime.UtcNow;
await client.UpdateStatusAsync(entity, cancellationToken);

return ReconciliationResult<V1DemoEntity>.Success(entity);
}

DeletedAsync

Important

The DeletedAsync method is informational only and executes asynchronously without guarantees. While it is called when a resource is deleted, it cannot ensure proper cleanup. For reliable resource cleanup, use finalizers.

This method is called when a resource is deleted, but should only be used for:

  • Logging deletion events
  • Triggering non-critical cleanup tasks
  • Updating external systems about the deletion
public async Task<ReconciliationResult<V1DemoEntity>> DeletedAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
// Log the deletion event
logger.LogInformation("Entity {Entity} was deleted.", entity);

// Update external systems if needed
await NotifyExternalSystem(entity, cancellationToken);

return ReconciliationResult<V1DemoEntity>.Success(entity);
}

Reconciliation Results

All reconciliation methods must return a ReconciliationResult<TEntity>. This provides a standardized way to communicate the outcome of reconciliation operations.

Success Results

Return a success result when reconciliation completes without errors:

public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
// Perform reconciliation
await ApplyDesiredState(entity, cancellationToken);

// Return success
return ReconciliationResult<V1DemoEntity>.Success(entity);
}

Failure Results

Return a failure result when reconciliation encounters an error:

public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
try
{
await ApplyDesiredState(entity, cancellationToken);
return ReconciliationResult<V1DemoEntity>.Success(entity);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reconcile entity {Name}", entity.Name());
return ReconciliationResult<V1DemoEntity>.Failure(
entity,
"Failed to apply desired state",
ex);
}
}

Requeuing Entities

You can request automatic requeuing by specifying a requeueAfter parameter on the result:

// Requeue after 5 minutes
return ReconciliationResult<V1DemoEntity>.Success(entity, TimeSpan.FromMinutes(5));

This is useful for:

  • Polling external resources
  • Implementing retry logic with backoff
  • Periodic status checks
  • Waiting for external dependencies

You can also enqueue an entity manually from within your controller using the EntityQueue<TEntity> delegate injected via DI. This allows you to schedule a reconciliation outside the normal return path:

public class V1DemoEntityController(
EntityQueue<V1DemoEntity> queue) : IEntityController<V1DemoEntity>
{
public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
// Schedule a follow-up reconciliation in 30 seconds without returning a result.
// The delegate returns Task<bool>; await it (false means the enqueue was dropped).
await queue(entity, ReconciliationType.Modified, ReconciliationTriggerSource.Operator,
TimeSpan.FromSeconds(30), retryCount: 0, cancellationToken);

return ReconciliationResult<V1DemoEntity>.Success(entity);
}
// ...
}
Durable Queue

By default, queue entries are held in memory and lost on operator restart. To back the queue with an external system, see Advanced Configuration — Queue Strategy.

Error Handling with Results

The ReconciliationResult provides structured error handling:

public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
if (!await ValidateConfiguration(entity))
{
return ReconciliationResult<V1DemoEntity>.Failure(
entity,
"Configuration validation failed: Required field 'DeploymentName' is empty",
requeueAfter: TimeSpan.FromMinutes(1));
}

try
{
await ReconcileInternal(entity, cancellationToken);
return ReconciliationResult<V1DemoEntity>.Success(entity);
}
catch (KubernetesException ex) when (ex.Status.Code == 409)
{
// Conflict - retry after short delay
return ReconciliationResult<V1DemoEntity>.Failure(
entity,
"Resource conflict detected",
ex,
requeueAfter: TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error during reconciliation");
return ReconciliationResult<V1DemoEntity>.Failure(
entity,
$"Reconciliation failed: {ex.Message}",
ex,
requeueAfter: TimeSpan.FromMinutes(5));
}
}

Important Considerations

What Triggers Reconciliation

With the default settings, your controller only sees events for spec changes (and deletion handling): status, label, and annotation updates are deduplicated away before they reach the queue. This is intentional — write status from your controller without re-triggering yourself. The exact rules and the ByResourceVersion alternative are described in Reconcile Strategy; the cache mechanics behind the deduplication in Caching.

Parallel Reconciliation

EntityQueueBackgroundService enforces two levels of concurrency control before calling your controller:

  1. A global SemaphoreSlim limits total concurrent reconciliations to ParallelReconciliationSettings.MaxParallelReconciliations (default: Environment.ProcessorCount * 2).
  2. A per-UID SemaphoreSlim prevents two reconciliations for the same entity from running simultaneously.

When a second event for the same UID arrives while a reconciliation is in progress, the configured ConflictStrategy applies (WaitForCompletion by default). See Advanced Configuration — Parallel Reconciliation to change the defaults.

RBAC Requirements

Controllers need appropriate RBAC permissions to function. Use the [EntityRbac] attribute to specify required permissions:

[EntityRbac(typeof(V1DemoEntity), Verbs = RbacVerb.All)]
public class V1DemoEntityController(
ILogger<V1DemoEntityController> logger,
IKubernetesClient client) : IEntityController<V1DemoEntity>
{
// Controller implementation
}

For more details about RBAC configuration, see the RBAC documentation.

Best Practices

Idempotency

  • Make your reconciliation logic idempotent
  • The same reconciliation should be safe to run multiple times
  • Always check the current state before making changes
public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
// Check if required resources exist
if (await IsDesiredState(entity, cancellationToken))
{
return ReconciliationResult<V1DemoEntity>.Success(entity);
}

// Only make changes if needed
await ApplyDesiredState(entity, cancellationToken);
return ReconciliationResult<V1DemoEntity>.Success(entity);
}

Error Handling

  • Use ReconciliationResult.Failure() for errors
  • Include meaningful error messages
  • Use the requeueAfter parameter for retry logic
  • Preserve exception information
public async Task<ReconciliationResult<V1DemoEntity>> ReconcileAsync(
V1DemoEntity entity,
CancellationToken cancellationToken)
{
try
{
await ReconcileInternal(entity, cancellationToken);
return ReconciliationResult<V1DemoEntity>.Success(entity);
}
catch (Exception ex)
{
logger.LogError(ex, "Error reconciling entity {Name}", entity.Name());
return ReconciliationResult<V1DemoEntity>.Failure(
entity,
$"Reconciliation failed: {ex.Message}",
ex,
requeueAfter: TimeSpan.FromMinutes(1));
}
}

Resource Management

  • Clean up resources when entities are deleted
  • Use finalizers to ensure proper cleanup
  • Monitor resource usage and implement limits

Performance

  • Keep reconciliation logic efficient
  • Avoid long-running operations in the reconciliation loop
  • Use background tasks for time-consuming operations

Filtering Watched Resources

By default, a controller's resource watcher observes all resources of the given entity type cluster-wide. If OperatorSettings.Namespace is configured, the watcher is automatically scoped to that namespace instead. You can further restrict the watched set using label selectors and field selectors.

Label Selectors

Implement IEntityLabelSelector<TEntity> to filter resources by label expressions. See the Kubernetes Label Selectors documentation for the full syntax reference. Register it when adding the controller:

public class MyDemoEntityLabelSelector : IEntityLabelSelector<V1DemoEntity>
{
public ValueTask<string?> GetLabelSelectorAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult<string?>(new EqualsLabelSelector("app", "my-operator"));
}

// Registration
builder.Services.AddKubernetesOperator()
.AddControllerWithLabelSelector<V1DemoEntityController, V1DemoEntity, MyDemoEntityLabelSelector>();

When no label selector is needed, the built-in DefaultEntityLabelSelector<TEntity> returns null (no filtering).

Field Selectors

Kubernetes field selectors filter by resource fields (e.g. metadata.name, metadata.namespace). They support = / == (equality) and != (inequality) operators, and only a small subset of fields per resource type. See the Kubernetes Field Selectors documentation for the full syntax reference.

Implement IEntityFieldSelector<TEntity> and register it when adding the controller:

public class MyDemoEntityFieldSelector : IEntityFieldSelector<V1DemoEntity>
{
public ValueTask<string?> GetFieldSelectorAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult<string?>(new EqualsFieldSelector("metadata.name", "my-demo-entity"));
}

// Registration
builder.Services.AddKubernetesOperator()
.AddControllerWithFieldSelector<V1DemoEntityController, V1DemoEntity, MyDemoEntityFieldSelector>();

When no field selector is needed, the built-in DefaultEntityFieldSelector<TEntity> returns null (no filtering).

Summary of Registration Methods

MethodLabel SelectorField Selector
AddController<TImpl, TEntity>()Default (none)Default (none)
AddControllerWithLabelSelector<TImpl, TEntity, TLabel>()CustomDefault (none)
AddControllerWithFieldSelector<TImpl, TEntity, TField>()Default (none)Custom

Declaring Selectors with Attributes

Instead of calling the AddControllerWith…Selector methods manually, you can annotate the controller class. The source generator picks the attribute up and emits the matching registration into RegisterControllers() / RegisterComponents():

[LabelSelector(typeof(MyDemoEntityLabelSelector))]
public class V1DemoEntityController : IEntityController<V1DemoEntity>
{
// ...
}

// or
[FieldSelector(typeof(MyDemoEntityFieldSelector))]
public class V1DemoEntityController : IEntityController<V1DemoEntity>
{
// ...
}

A controller supports one selector kind at a time (label or field selector), matching the builder methods. Declaring both [LabelSelector] and [FieldSelector] on the same controller is a compile-time error (KOG001); split the work into two controllers instead.

Multiple Controllers per Entity

The same entity type can be served by multiple independent controllers, each with its own label or field selector. Every registration creates a fully separate watch → queue → reconcile pipeline, so controllers with overlapping selectors both reconcile an entity that matches both:

builder.Services.AddKubernetesOperator()
.AddControllerWithLabelSelector<ManagedController, V1DemoEntity, ManagedLabelSelector>()
.AddControllerWithLabelSelector<ConfigController, V1DemoEntity, ConfigLabelSelector>();

Notes:

  • Each pipeline maintains its own watch connection, queue, and deduplication cache, so the controllers never suppress each other's events — even for the same object.
  • The same object is never reconciled by two of its controllers concurrently: a per-entity-type coordinator serializes reconciliation per object UID (and enforces one shared parallelism budget). Finalizers are entity-global — they are attached once and, under that same lock, are not executed concurrently or duplicated solely because multiple controllers match the object. This is not general exactly-once execution (e.g. a process crash between an external side effect and the finalizer removal can still re-run it), so finalizers must remain idempotent.
  • Registering the exact same controller/selector combination twice throws at registration time (it would only open a redundant watch stream).
  • The EntityQueue<TEntity> requeue delegate routes to the pipeline the current reconciliation originated from. Inject it into the controller (or another scoped service); with multiple controllers per entity it cannot be resolved from a singleton.
  • N controllers means N watch connections per entity type by default. If that becomes a concern (many controllers, many objects, overlapping selectors), switch to the shared watch mode — see Watch Strategy.
warning

Multiple controllers per entity are supported only on the default in-memory queue with non-custom leader election (LeaderElectionType.None or LeaderElectionType.Single), where each pipeline owns its queue and reconciler. Under QueueStrategy.Custom or LeaderElectionType.Custom all pipelines share a single user-owned queue and a single reconciler with no per-pipeline identity, so only one controller per entity is allowed — registering a second one throws an InvalidOperationException.

Common Pitfalls

  1. Infinite Loops: Avoid creating reconciliation loops that trigger themselves
  2. Missing Error Handling: Always handle potential errors
  3. Resource Leaks: Ensure proper cleanup of resources
  4. Missing RBAC Configuration: Configure appropriate permissions
  5. Status Updates: Remember that status updates don't trigger reconciliation