-
Notifications
You must be signed in to change notification settings - Fork 86
feat(controllers): Enable multiple controllers with different label selectors for the same entity type (V2) #1070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
146ac01
e1674aa
7ebc03e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,108 @@ | ||||||||||||||||||||||||||||||
| // Licensed to the .NET Foundation under one or more agreements. | ||||||||||||||||||||||||||||||
| // The .NET Foundation licenses this file to you under the Apache 2.0 License. | ||||||||||||||||||||||||||||||
| // See the LICENSE file in the project root for more information. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| namespace KubeOps.Operator.Reconciliation; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// <summary> | ||||||||||||||||||||||||||||||
| /// Evaluates a Kubernetes label-selector expression against an entity's label dictionary. | ||||||||||||||||||||||||||||||
| /// 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> | ||||||||||||||||||||||||||||||
|
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> | |
| /// 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
AI
Apr 11, 2026
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| using k8s.Models; | ||
|
|
||
| using KubeOps.Abstractions.Builder; | ||
| using KubeOps.Abstractions.Entities; | ||
| using KubeOps.Abstractions.Reconciliation; | ||
| using KubeOps.Abstractions.Reconciliation.Controller; | ||
| using KubeOps.Abstractions.Reconciliation.Finalizer; | ||
|
|
@@ -115,8 +116,11 @@ await entityQueue | |
| cancellationToken); | ||
|
|
||
| await using var scope = serviceProvider.CreateAsyncScope(); | ||
| var controller = scope.ServiceProvider.GetRequiredService<IEntityController<TEntity>>(); | ||
| var result = await controller.DeletedAsync(reconciliationContext.Entity, cancellationToken); | ||
| var result = await DispatchToMatchingControllers( | ||
| scope.ServiceProvider, | ||
| reconciliationContext.Entity, | ||
| (ctrl, entity, ct) => ctrl.DeletedAsync(entity, ct), | ||
| cancellationToken); | ||
|
|
||
| if (result.IsSuccess) | ||
| { | ||
|
|
@@ -150,8 +154,50 @@ await entityQueue | |
| } | ||
| } | ||
|
|
||
| var controller = scope.ServiceProvider.GetRequiredService<IEntityController<TEntity>>(); | ||
| return await controller.ReconcileAsync(entity, cancellationToken); | ||
| return await DispatchToMatchingControllers( | ||
| scope.ServiceProvider, | ||
| entity, | ||
| (ctrl, e, ct) => ctrl.ReconcileAsync(e, ct), | ||
| cancellationToken); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets all <see cref="IEntityController{TEntity}"/> registrations whose <see cref="IEntityController{TEntity}.LabelFilter"/> | ||
| /// matches the given entity's labels, then calls <paramref name="operation"/> on each in registration order. | ||
| /// On the first failure the chain is short-circuited and that failure result is returned. | ||
| /// If no controller matches, a success result is returned and a warning is logged. | ||
| /// </summary> | ||
| private async Task<ReconciliationResult<TEntity>> DispatchToMatchingControllers( | ||
| IServiceProvider services, | ||
| TEntity entity, | ||
| Func<IEntityController<TEntity>, TEntity, CancellationToken, Task<ReconciliationResult<TEntity>>> operation, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var entityLabels = (IReadOnlyDictionary<string, string>?)entity.Labels() | ||
| ?? new Dictionary<string, string>(); | ||
|
|
||
| var controllers = services | ||
| .GetServices<IEntityController<TEntity>>() | ||
| .Where(c => LabelSelectorMatcher.Matches(c.LabelFilter, entityLabels)) | ||
| .ToList(); | ||
|
|
||
| if (controllers.Count == 0) | ||
| { | ||
| logger.LogWarning( | ||
| """No controller matched labels for "{Kind}/{Name}". Skipping.""", | ||
| entity.Kind, | ||
| entity.Name()); | ||
| return ReconciliationResult<TEntity>.Success(entity); | ||
|
Comment on lines
+184
to
+190
|
||
| } | ||
|
|
||
| ReconciliationResult<TEntity> result = ReconciliationResult<TEntity>.Success(entity); | ||
| foreach (var controller in controllers) | ||
| { | ||
| result = await operation(controller, result.Entity, cancellationToken); | ||
| if (!result.IsSuccess) return result; | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| private async Task<ReconciliationResult<TEntity>> ReconcileFinalizersSequential(TEntity entity, CancellationToken cancellationToken) | ||
|
|
||
There was a problem hiding this comment.
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).