Skip to content

feat(controllers): Enable multiple controllers with different label selectors for the same entity type (V2)#1070

Closed
stevefan1999-personal wants to merge 3 commits intodotnet:mainfrom
stevefan1999-personal:feat/multi-controller-label-filter
Closed

feat(controllers): Enable multiple controllers with different label selectors for the same entity type (V2)#1070
stevefan1999-personal wants to merge 3 commits intodotnet:mainfrom
stevefan1999-personal:feat/multi-controller-label-filter

Conversation

@stevefan1999-personal
Copy link
Copy Markdown
Contributor

Multi-controller dispatch via LabelFilter on IEntityController<TEntity>

Fixes #909. A continuation of #911

Overview

Adds support for registering multiple controllers for the same Kubernetes entity type, each handling a different subset of resources based on label selectors. The implementation follows the approach requested in review: a single property on the existing interface, runtime dispatch, and no breaking changes.

Changes

IEntityController<TEntity> — one new default property

// Default: null = catch-all (matches all entities, preserves existing behaviour)
string? LabelFilter { get; } => null;

Controllers that need to scope themselves declare a selector expression:

public string? LabelFilter =>
    new LabelSelector[] { new EqualsSelector("env", "prod") }.ToExpression();

OperatorBuilderAddScoped instead of TryAddScoped

One line change. TryAddScoped silently drops duplicate service registrations; AddScoped appends them, enabling GetServices<IEntityController<TEntity>>() to resolve all registered controllers for an entity type.

Reconciler<TEntity> — dispatch to all matching controllers

New DispatchToMatchingControllers method replaces the single GetRequiredService call. It resolves all registered controllers, filters by LabelFilter against the entity's labels, and calls each matching one in registration order. On the first failure the chain short-circuits.

LabelSelectorMatcher — new internal utility

Evaluates KubeOps set-based selector expressions (key in (v1,v2), key notin (v1,v2), key, !key) against an entity's label dictionary at runtime. Handles multi-clause AND semantics and ignores commas inside parentheses.

What was explicitly NOT changed

  • IEntityLabelSelector<TEntity> — untouched
  • ResourceWatcher<TEntity> — untouched; one watcher per entity type regardless of controller count
  • AddController<TImplementation, TEntity>() signature — unchanged
  • All existing controller implementations — work without modification

Tests

  • LabelSelectorMatcher.Test.cs — 22 unit tests covering all 4 selector operators, multi-clause AND, inner-comma handling, empty/null labels
  • Reconciler.MultiController.Test.cs — 12 tests: catch-all null filter, label match/no-match, ordering, failure short-circuit, entity mutation pass-through, DeletedAsync path
  • OperatorBuilder.Test.cs — 3 new tests: multiple registrations in service collection, runtime GetServices resolution, no duplicate ResourceWatcher registered
  • Fixed existing Reconciler.Test.cs mock: updated from GetService(IEntityController<T>) to GetService(IEnumerable<IEntityController<T>>) to match the new dispatch path

Example

// Register multiple controllers for the same entity type
builder.AddController<ProdController, MyEntity>();   // LabelFilter => "env in (prod)"
builder.AddController<DevController, MyEntity>();    // LabelFilter => "env in (dev)"
builder.AddController<AuditController, MyEntity>();  // LabelFilter => null (all entities)

Backward compatibility

A controller with no LabelFilter override returns null, which matches every entity — identical to existing behaviour. No existing code requires changes.

Copilot AI review requested due to automatic review settings March 30, 2026 13:41
@stevefan1999-personal
Copy link
Copy Markdown
Contributor Author

@dotnet-policy-service agree

@dotnet-policy-service agree

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for registering multiple IEntityController<TEntity> implementations for the same entity type and dispatching reconciliation/deletion events to all controllers whose label selector matches the entity’s labels.

Changes:

  • Adds LabelFilter as a defaulted property on IEntityController<TEntity> to allow per-controller label scoping.
  • Updates DI registration to allow multiple scoped controller registrations for the same entity type.
  • Introduces runtime dispatch in Reconciler<TEntity> and a LabelSelectorMatcher utility with accompanying unit tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs Updates existing reconciler tests to mock resolving IEnumerable<IEntityController<T>>.
test/KubeOps.Operator.Test/Reconciliation/Reconciler.MultiController.Test.cs Adds tests covering multi-controller dispatch, ordering, and short-circuit behavior.
test/KubeOps.Operator.Test/Reconciliation/LabelSelectorMatcher.Test.cs Adds unit tests for selector parsing/matching behavior.
test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs Verifies multiple controller registrations and that watchers aren’t duplicated.
src/KubeOps.Operator/Reconciliation/Reconciler.cs Replaces single-controller resolution with dispatch to all matching controllers.
src/KubeOps.Operator/Reconciliation/LabelSelectorMatcher.cs Adds selector-expression matcher for KubeOps’ set-based selector syntax.
src/KubeOps.Operator/Builder/OperatorBuilder.cs Switches controller DI registration from TryAddScoped to AddScoped to allow multiples.
src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs Adds LabelFilter default interface property and documentation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +20 to +26
internal static bool Matches(string? selector, IReadOnlyDictionary<string, string>? entityLabels)
{
if (selector is null) return true;
entityLabels ??= new Dictionary<string, string>();

return SplitTopLevel(selector).All(clause => MatchClause(clause, entityLabels));
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches treats only null as a catch-all, but an empty/whitespace selector string currently matches nothing (because it becomes an empty clause). LabelSelector[].ToExpression() returns an empty string for an empty selector list, and Kubernetes semantics for an empty selector are "match all". Consider handling string.IsNullOrWhiteSpace(selector) as true and/or skipping empty clauses in SplitTopLevel (also covers trailing commas). Add a unit test for the empty-string case to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +14
/// Supports the set-based formats emitted by KubeOps.KubernetesClient label-selector types:
/// <list type="bullet">
/// <item><c>key in (v1,v2)</c> – EqualsSelector</item>
/// <item><c>key notin (v1,v2)</c> – NotEqualsSelector</item>
/// <item><c>key</c> – ExistsSelector</item>
/// <item><c>!key</c> – NotExistsSelector</item>
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc says this matcher "supports the set-based formats emitted" by the KubeOps label-selector types, but the implementation also supports key=value and key!=value clauses. Either document these additional supported operators or remove them to keep behavior and docs aligned.

Suggested change
/// Supports the set-based formats emitted by KubeOps.KubernetesClient label-selector types:
/// <list type="bullet">
/// <item><c>key in (v1,v2)</c> – EqualsSelector</item>
/// <item><c>key notin (v1,v2)</c> – NotEqualsSelector</item>
/// <item><c>key</c> – ExistsSelector</item>
/// <item><c>!key</c> – NotExistsSelector</item>
/// Supports the set-based formats emitted by KubeOps.KubernetesClient label-selector types, as well as equality-based clauses:
/// <list type="bullet">
/// <item><c>key in (v1,v2)</c> – EqualsSelector</item>
/// <item><c>key notin (v1,v2)</c> – NotEqualsSelector</item>
/// <item><c>key</c> – ExistsSelector</item>
/// <item><c>!key</c> – NotExistsSelector</item>
/// <item><c>key=value</c> – equality match</item>
/// <item><c>key!=value</c> – inequality match</item>

Copilot uses AI. Check for mistakes.
Comment on lines +184 to +190
if (controllers.Count == 0)
{
logger.LogWarning(
"""No controller matched labels for "{Kind}/{Name}". Skipping.""",
entity.Kind,
entity.Name());
return ReconciliationResult<TEntity>.Success(entity);
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging a Warning for every event where no controller matches can create very noisy logs in the intended use-case (multiple scoped controllers, no catch-all). Since "no match" can be an expected outcome, consider lowering this to Information/Debug, or otherwise rate-limiting/aggregating it (e.g., include a counter, log once per kind, or gate behind a setting).

Copilot uses AI. Check for mistakes.
///
/// When multiple controllers are registered for the same entity type the reconciler evaluates every
/// controller's <see cref="LabelFilter"/> against the entity's labels and dispatches to all that
/// match, allowing fine-grained fan-out without touching the watcher or DI registration plumbing.
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment mentions fan-out works "without touching the watcher or DI registration plumbing", but enabling multiple controllers for the same entity type does require DI registration changes (e.g., TryAddScoped -> AddScoped). Reword this to avoid misleading readers (e.g., emphasize no watcher changes / no signature changes instead).

Suggested change
/// match, allowing fine-grained fan-out without touching the watcher or DI registration plumbing.
/// match, allowing fine-grained fan-out without changing the watcher logic or controller method signatures.

Copilot uses AI. Check for mistakes.
Comment on lines 45 to 50
public IOperatorBuilder AddController<TImplementation, TEntity>()
where TImplementation : class, IEntityController<TEntity>
where TEntity : IKubernetesObject<V1ObjectMeta>
{
Services.TryAddScoped<IEntityController<TEntity>, TImplementation>();
Services.AddScoped<IEntityController<TEntity>, TImplementation>();
Services.TryAddSingleton<IReconciler<TEntity>, Reconciler<TEntity>>();
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching to AddScoped enables multiple IEntityController<TEntity> registrations, but note that ResourceWatcher<TEntity> still uses a single IEntityLabelSelector<TEntity> for server-side watch filtering. If users register multiple controllers via AddController<TImplementation,TEntity,TLabelSelector>() with different selectors, only the first selector is used (due to TryAddSingleton), which can prevent events for other controllers from ever reaching the reconciler. Consider documenting this limitation clearly and/or failing fast when multiple different label selectors are registered for the same entity type.

Copilot uses AI. Check for mistakes.
@kimpenhaus
Copy link
Copy Markdown
Collaborator

Hey @stevefan1999-personal thanks for your contribution and sorry for the late reply. In general I like the idea of having multiple controller/finalizer registrations and dispatching events to those being responsible.

Based on requests from the past there is just one thing I would like to change. This pull request covers a single - most probably your - use-case. dispatching based on labels. we had some requests in the past like #803 or to reconcile based on status updates (not by generation - sorry couldn't actually find the ticket by now, might be in discord). If we could implement this feature a bit more generic/flexibel that would help in resolving those requests as well.

I would like to see something like:

  • IEntityController<TEntity> and IEntityFinalizer<TEntity> get a ValueTask<bool> ShouldHandle(TEntity entity); instead of the LabelFilter

  • both should have a default implementation with true - like:

    ValueTask<bool> ShouldHandle(TEntity entity)
        => ValueTask.FromResult(true);
  • LabelSelectorMatcher could be dropped - every consumer can implement what he needs to determine whether the controller/finalizer is responsible or not

  • when dispatching the entity ShouldHandle is checked - like (for the finalizers handling I am not pretty sure now how to handle that - but we could have a thought on that):

        foreach (var controller in controllers)
        {
            if (await controller.ShouldHandle(entity))
            {
                return await controller.ReconcileAsync(entity, cancellationToken);
            }
        }

        return ReconciliationResult<TEntity>.Failure(entity, "No responsible controller found.");

this reduces the specific use-case complexity inside the resource-watcher and give more opportunities/flexibilities to the consumers. what do you think?

@kimpenhaus
Copy link
Copy Markdown
Collaborator

replaced by #1084

@kimpenhaus kimpenhaus closed this Apr 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature/bug]: Multiple entity controllers with different label selectors

3 participants