From 60806764db91a51d9b07fb7c69ff7f356154bc9c Mon Sep 17 00:00:00 2001 From: Marcus Kimpenhaus Date: Wed, 22 Apr 2026 21:26:25 +0200 Subject: [PATCH] feat(kubernetes-client): add field selector support - Replaced individual label selector classes with a unified `LabelSelector` hierarchy under `Selectors` namespace. - Added `FieldSelector` hierarchy following a similar model to support field-based filtering. - Extended `KubernetesClient` and `ResourceWatcher` to handle both label and field selectors for enhanced query capabilities. - Updated `OperatorBuilder` with methods to register custom label and field selectors for controllers. - Improved test coverage with label and field selector functionality verification. - Removed obsolete or redundant selector implementations. --- .../operator/building-blocks/controllers.mdx | 60 +++++++++++++++++++ .../Builder/IOperatorBuilder.cs | 19 +++++- .../DefaultEntityFieldSelector{TEntity}.cs | 15 +++++ .../DefaultEntityLabelSelector{TEntity}.cs | 5 +- .../Entities/IEntityFieldSelector{TEntity}.cs | 19 ++++++ .../IKubernetesClient.cs | 4 +- .../KubernetesClient.cs | 3 + .../LabelSelectors/LabelSelector.cs | 24 -------- .../Selectors/EqualsFieldSelector.cs | 16 +++++ .../EqualsLabelSelector.cs} | 4 +- .../ExistsLabelSelector.cs} | 4 +- .../Selectors/FieldSelector.cs | 13 ++++ .../Selectors/KubernetesSelector.cs | 24 ++++++++ .../Selectors/LabelSelector.cs | 13 ++++ .../Selectors/NotEqualsFieldSelector.cs | 16 +++++ .../NotEqualsLabelSelector.cs} | 4 +- .../NotExistsLabelSelector.cs} | 4 +- .../SelectorExtensions.cs} | 12 +++- .../Builder/OperatorBuilder.cs | 14 ++++- .../LeaderAwareResourceWatcher{TEntity}.cs | 2 + .../Watcher/ResourceWatcher{TEntity}.cs | 2 + .../LabelSelector.Test.cs | 23 ------- .../Selectors/FieldSelector.Test.cs | 42 +++++++++++++ .../Selectors/LabelSelector.Test.cs | 23 +++++++ .../Builder/OperatorBuilder.Test.cs | 31 +++++++++- .../LeaderAwareResourceWatcher.Test.cs | 1 + .../Watcher/ResourceWatcher{TEntity}.Test.cs | 10 +++- 27 files changed, 338 insertions(+), 69 deletions(-) create mode 100644 src/KubeOps.Abstractions/Entities/DefaultEntityFieldSelector{TEntity}.cs create mode 100644 src/KubeOps.Abstractions/Entities/IEntityFieldSelector{TEntity}.cs delete mode 100644 src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs create mode 100644 src/KubeOps.KubernetesClient/Selectors/EqualsFieldSelector.cs rename src/KubeOps.KubernetesClient/{LabelSelectors/EqualsSelector.cs => Selectors/EqualsLabelSelector.cs} (82%) rename src/KubeOps.KubernetesClient/{LabelSelectors/ExistsSelector.cs => Selectors/ExistsLabelSelector.cs} (80%) create mode 100644 src/KubeOps.KubernetesClient/Selectors/FieldSelector.cs create mode 100644 src/KubeOps.KubernetesClient/Selectors/KubernetesSelector.cs create mode 100644 src/KubeOps.KubernetesClient/Selectors/LabelSelector.cs create mode 100644 src/KubeOps.KubernetesClient/Selectors/NotEqualsFieldSelector.cs rename src/KubeOps.KubernetesClient/{LabelSelectors/NotEqualsSelector.cs => Selectors/NotEqualsLabelSelector.cs} (82%) rename src/KubeOps.KubernetesClient/{LabelSelectors/NotExistsSelector.cs => Selectors/NotExistsLabelSelector.cs} (80%) rename src/KubeOps.KubernetesClient/{LabelSelectors/Extensions.cs => Selectors/SelectorExtensions.cs} (55%) delete mode 100644 test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs create mode 100644 test/KubeOps.KubernetesClient.Test/Selectors/FieldSelector.Test.cs create mode 100644 test/KubeOps.KubernetesClient.Test/Selectors/LabelSelector.Test.cs diff --git a/docs/docs/operator/building-blocks/controllers.mdx b/docs/docs/operator/building-blocks/controllers.mdx index 4532b706..b6f466ad 100644 --- a/docs/docs/operator/building-blocks/controllers.mdx +++ b/docs/docs/operator/building-blocks/controllers.mdx @@ -361,6 +361,66 @@ public async Task> ReconcileAsync( - 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` to filter resources by label expressions. +See the [Kubernetes Label Selectors documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) for the full syntax reference. +Register it when adding the controller: + +```csharp +public class MyDemoEntityLabelSelector : IEntityLabelSelector +{ + public ValueTask GetLabelSelectorAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(new EqualsLabelSelector("app", "my-operator")); +} + +// Registration +builder.Services.AddKubernetesOperator() + .AddControllerWithLabelSelector(); +``` + +When no label selector is needed, the built-in `DefaultEntityLabelSelector` 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](https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/) for the full syntax reference. + +Implement `IEntityFieldSelector` and register it when adding the controller: + +```csharp +public class MyDemoEntityFieldSelector : IEntityFieldSelector +{ + public ValueTask GetFieldSelectorAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(new EqualsFieldSelector("metadata.name", "my-demo-entity")); +} + +// Registration +builder.Services.AddKubernetesOperator() + .AddControllerWithFieldSelector(); +``` + +When no field selector is needed, the built-in `DefaultEntityFieldSelector` returns `null` +(no filtering). + +### Summary of Registration Methods + +| Method | Label Selector | Field Selector | +|---|---|---| +| `AddController()` | Default (none) | Default (none) | +| `AddControllerWithLabelSelector()` | Custom | Default (none) | +| `AddControllerWithFieldSelector()` | Default (none) | Custom | + ## Common Pitfalls 1. **Infinite Loops**: Avoid creating reconciliation loops that trigger themselves diff --git a/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs b/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs index 2cc630f2..7829accc 100644 --- a/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs +++ b/src/KubeOps.Abstractions/Builder/IOperatorBuilder.cs @@ -41,18 +41,33 @@ IOperatorBuilder AddController() where TEntity : IKubernetesObject; /// - /// Add a controller implementation for a specific entity to the operator. + /// Add a controller implementation for a specific entity to the operator, + /// with a custom label selector. /// The metadata for the entity must be added as well. /// /// Implementation type of the controller. /// Entity type. /// Label Selector type. /// The builder for chaining. - IOperatorBuilder AddController() + IOperatorBuilder AddControllerWithLabelSelector() where TImplementation : class, IEntityController where TEntity : IKubernetesObject where TLabelSelector : class, IEntityLabelSelector; + /// + /// Add a controller implementation for a specific entity to the operator, + /// with a custom field selector. + /// The metadata for the entity must be added as well. + /// + /// Implementation type of the controller. + /// Entity type. + /// Field Selector type. + /// The builder for chaining. + IOperatorBuilder AddControllerWithFieldSelector() + where TImplementation : class, IEntityController + where TEntity : IKubernetesObject + where TFieldSelector : class, IEntityFieldSelector; + /// /// Add a finalizer implementation for a specific entity. /// This adds the implementation as a transient service and registers diff --git a/src/KubeOps.Abstractions/Entities/DefaultEntityFieldSelector{TEntity}.cs b/src/KubeOps.Abstractions/Entities/DefaultEntityFieldSelector{TEntity}.cs new file mode 100644 index 00000000..39db882a --- /dev/null +++ b/src/KubeOps.Abstractions/Entities/DefaultEntityFieldSelector{TEntity}.cs @@ -0,0 +1,15 @@ +// 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. + +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +public sealed class DefaultEntityFieldSelector : IEntityFieldSelector + where TEntity : IKubernetesObject +{ + public ValueTask GetFieldSelectorAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(null); +} diff --git a/src/KubeOps.Abstractions/Entities/DefaultEntityLabelSelector{TEntity}.cs b/src/KubeOps.Abstractions/Entities/DefaultEntityLabelSelector{TEntity}.cs index da99c811..21ff3175 100644 --- a/src/KubeOps.Abstractions/Entities/DefaultEntityLabelSelector{TEntity}.cs +++ b/src/KubeOps.Abstractions/Entities/DefaultEntityLabelSelector{TEntity}.cs @@ -7,8 +7,9 @@ namespace KubeOps.Abstractions.Entities; -public class DefaultEntityLabelSelector : IEntityLabelSelector +public sealed class DefaultEntityLabelSelector : IEntityLabelSelector where TEntity : IKubernetesObject { - public ValueTask GetLabelSelectorAsync(CancellationToken cancellationToken) => ValueTask.FromResult(null); + public ValueTask GetLabelSelectorAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(null); } diff --git a/src/KubeOps.Abstractions/Entities/IEntityFieldSelector{TEntity}.cs b/src/KubeOps.Abstractions/Entities/IEntityFieldSelector{TEntity}.cs new file mode 100644 index 00000000..fcc2251b --- /dev/null +++ b/src/KubeOps.Abstractions/Entities/IEntityFieldSelector{TEntity}.cs @@ -0,0 +1,19 @@ +// 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. + +using k8s; +using k8s.Models; + +namespace KubeOps.Abstractions.Entities; + +// This is the same pattern used by Microsoft on ILogger. +// An alternative would be to use a KeyedSingleton when registering this however that's only valid from .NET 8 and above. +// Other methods are far less elegant +#pragma warning disable S2326 +public interface IEntityFieldSelector + where TEntity : IKubernetesObject +{ + ValueTask GetFieldSelectorAsync(CancellationToken cancellationToken); +} +#pragma warning restore S2326 diff --git a/src/KubeOps.KubernetesClient/IKubernetesClient.cs b/src/KubeOps.KubernetesClient/IKubernetesClient.cs index f340a99a..9ab910dc 100644 --- a/src/KubeOps.KubernetesClient/IKubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/IKubernetesClient.cs @@ -10,7 +10,7 @@ using k8s.Models; using KubeOps.Abstractions.Entities; -using KubeOps.KubernetesClient.LabelSelectors; +using KubeOps.KubernetesClient.Selectors; namespace KubeOps.KubernetesClient; @@ -740,6 +740,7 @@ Watcher Watch( /// Defaults to changes from the beginning of history. /// /// A string, representing an optional label selector for filtering watched objects. + /// A string, representing an optional field selector for filtering watched objects. /// /// Parameter to tell the server to send BOOKMARK events. However, if the server has no implementation or /// configuration for bookmarks, this flag is ignored. @@ -751,6 +752,7 @@ Watcher Watch( string? @namespace = null, string? resourceVersion = null, string? labelSelector = null, + string? fieldSelector = null, bool? allowWatchBookmarks = null, CancellationToken cancellationToken = default) where TEntity : IKubernetesObject; diff --git a/src/KubeOps.KubernetesClient/KubernetesClient.cs b/src/KubeOps.KubernetesClient/KubernetesClient.cs index 0f799ce8..c4f8d6a9 100644 --- a/src/KubeOps.KubernetesClient/KubernetesClient.cs +++ b/src/KubeOps.KubernetesClient/KubernetesClient.cs @@ -438,6 +438,7 @@ public Watcher Watch( string? @namespace = null, string? resourceVersion = null, string? labelSelector = null, + string? fieldSelector = null, bool? allowWatchBookmarks = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where TEntity : IKubernetesObject @@ -452,6 +453,7 @@ public Watcher Watch( @namespace, metadata.PluralName, allowWatchBookmarks: allowWatchBookmarks, + fieldSelector: fieldSelector, labelSelector: labelSelector, resourceVersion: resourceVersion, watch: true, @@ -461,6 +463,7 @@ public Watcher Watch( metadata.Version, metadata.PluralName, allowWatchBookmarks: allowWatchBookmarks, + fieldSelector: fieldSelector, labelSelector: labelSelector, resourceVersion: resourceVersion, watch: true, diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs b/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs deleted file mode 100644 index b4ac99b8..00000000 --- a/src/KubeOps.KubernetesClient/LabelSelectors/LabelSelector.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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.KubernetesClient.LabelSelectors; - -/// -/// Different label selectors for querying the Kubernetes API. -/// -public abstract record LabelSelector -{ - /// - /// Cast the label selector to a string. - /// - /// The selector. - /// A string representation of the label selector. - public static implicit operator string(LabelSelector selector) => selector.ToExpression(); - - /// - /// Create an expression from the label selector. - /// - /// A string that represents the label selector. - protected abstract string ToExpression(); -} diff --git a/src/KubeOps.KubernetesClient/Selectors/EqualsFieldSelector.cs b/src/KubeOps.KubernetesClient/Selectors/EqualsFieldSelector.cs new file mode 100644 index 00000000..91b71a2b --- /dev/null +++ b/src/KubeOps.KubernetesClient/Selectors/EqualsFieldSelector.cs @@ -0,0 +1,16 @@ +// 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.KubernetesClient.Selectors; + +/// +/// Field-selector that checks if a certain field equals a specific value. +/// Produces the expression field=value. +/// +/// The field path (e.g. metadata.name). +/// The required value. +public record EqualsFieldSelector(string Field, string Value) : FieldSelector +{ + protected override string ToExpression() => $"{Field}={Value}"; +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs b/src/KubeOps.KubernetesClient/Selectors/EqualsLabelSelector.cs similarity index 82% rename from src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs rename to src/KubeOps.KubernetesClient/Selectors/EqualsLabelSelector.cs index c7f1f3e0..f002c2a3 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/EqualsSelector.cs +++ b/src/KubeOps.KubernetesClient/Selectors/EqualsLabelSelector.cs @@ -2,7 +2,7 @@ // 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.KubernetesClient.LabelSelectors; +namespace KubeOps.KubernetesClient.Selectors; /// /// Label-selector that checks if a certain label contains @@ -11,7 +11,7 @@ namespace KubeOps.KubernetesClient.LabelSelectors; /// /// The label that needs to equal to one of the values. /// The possible values. -public record EqualsSelector(string Label, params string[] Values) : LabelSelector +public record EqualsLabelSelector(string Label, params string[] Values) : LabelSelector { protected override string ToExpression() => $"{Label} in ({string.Join(",", Values)})"; } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs b/src/KubeOps.KubernetesClient/Selectors/ExistsLabelSelector.cs similarity index 80% rename from src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs rename to src/KubeOps.KubernetesClient/Selectors/ExistsLabelSelector.cs index 62ddddb2..9c230a2d 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/ExistsSelector.cs +++ b/src/KubeOps.KubernetesClient/Selectors/ExistsLabelSelector.cs @@ -2,13 +2,13 @@ // 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.KubernetesClient.LabelSelectors; +namespace KubeOps.KubernetesClient.Selectors; /// /// Selector that checks if a certain label exists. /// /// The label that needs to exist on the entity/resource. -public record ExistsSelector(string Label) : LabelSelector +public record ExistsLabelSelector(string Label) : LabelSelector { protected override string ToExpression() => $"{Label}"; } diff --git a/src/KubeOps.KubernetesClient/Selectors/FieldSelector.cs b/src/KubeOps.KubernetesClient/Selectors/FieldSelector.cs new file mode 100644 index 00000000..d97cec83 --- /dev/null +++ b/src/KubeOps.KubernetesClient/Selectors/FieldSelector.cs @@ -0,0 +1,13 @@ +// 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.KubernetesClient.Selectors; + +/// +/// Different field selectors for querying the Kubernetes API. +/// +/// Kubernetes Field Selectors +#pragma warning disable S2094 +public abstract record FieldSelector : KubernetesSelector; +#pragma warning restore S2094 diff --git a/src/KubeOps.KubernetesClient/Selectors/KubernetesSelector.cs b/src/KubeOps.KubernetesClient/Selectors/KubernetesSelector.cs new file mode 100644 index 00000000..a708d7a7 --- /dev/null +++ b/src/KubeOps.KubernetesClient/Selectors/KubernetesSelector.cs @@ -0,0 +1,24 @@ +// 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.KubernetesClient.Selectors; + +/// +/// Common base record for all Kubernetes selector types (label and field selectors). +/// +public abstract record KubernetesSelector +{ + /// + /// Cast the selector to a string expression. + /// + /// The selector. + /// A string representation of the selector. + public static implicit operator string(KubernetesSelector selector) => selector.ToExpression(); + + /// + /// Create an expression string from the selector. + /// + /// A string that represents the selector expression. + protected abstract string ToExpression(); +} diff --git a/src/KubeOps.KubernetesClient/Selectors/LabelSelector.cs b/src/KubeOps.KubernetesClient/Selectors/LabelSelector.cs new file mode 100644 index 00000000..98602afc --- /dev/null +++ b/src/KubeOps.KubernetesClient/Selectors/LabelSelector.cs @@ -0,0 +1,13 @@ +// 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.KubernetesClient.Selectors; + +/// +/// Different label selectors for querying the Kubernetes API. +/// +/// Kubernetes Label Selectors +#pragma warning disable S2094 +public abstract record LabelSelector : KubernetesSelector; +#pragma warning restore S2094 diff --git a/src/KubeOps.KubernetesClient/Selectors/NotEqualsFieldSelector.cs b/src/KubeOps.KubernetesClient/Selectors/NotEqualsFieldSelector.cs new file mode 100644 index 00000000..e9b1ebdc --- /dev/null +++ b/src/KubeOps.KubernetesClient/Selectors/NotEqualsFieldSelector.cs @@ -0,0 +1,16 @@ +// 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.KubernetesClient.Selectors; + +/// +/// Field-selector that checks if a certain field does not equal a specific value. +/// Produces the expression field!=value. +/// +/// The field path (e.g. metadata.name). +/// The excluded value. +public record NotEqualsFieldSelector(string Field, string Value) : FieldSelector +{ + protected override string ToExpression() => $"{Field}!={Value}"; +} diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs b/src/KubeOps.KubernetesClient/Selectors/NotEqualsLabelSelector.cs similarity index 82% rename from src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs rename to src/KubeOps.KubernetesClient/Selectors/NotEqualsLabelSelector.cs index 0b7220f4..169a11d5 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/NotEqualsSelector.cs +++ b/src/KubeOps.KubernetesClient/Selectors/NotEqualsLabelSelector.cs @@ -2,7 +2,7 @@ // 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.KubernetesClient.LabelSelectors; +namespace KubeOps.KubernetesClient.Selectors; /// /// Label-selector that checks if a certain label does not contain @@ -11,7 +11,7 @@ namespace KubeOps.KubernetesClient.LabelSelectors; /// /// The label that must not equal to one of the values. /// The possible values. -public record NotEqualsSelector(string Label, params string[] Values) : LabelSelector +public record NotEqualsLabelSelector(string Label, params string[] Values) : LabelSelector { protected override string ToExpression() => $"{Label} notin ({string.Join(",", Values)})"; } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs b/src/KubeOps.KubernetesClient/Selectors/NotExistsLabelSelector.cs similarity index 80% rename from src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs rename to src/KubeOps.KubernetesClient/Selectors/NotExistsLabelSelector.cs index 8a96f44c..8277cdec 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/NotExistsSelector.cs +++ b/src/KubeOps.KubernetesClient/Selectors/NotExistsLabelSelector.cs @@ -2,13 +2,13 @@ // 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.KubernetesClient.LabelSelectors; +namespace KubeOps.KubernetesClient.Selectors; /// /// Selector that checks if a certain label does not exist. /// /// The label that must not exist on the entity/resource. -public record NotExistsSelector(string Label) : LabelSelector +public record NotExistsLabelSelector(string Label) : LabelSelector { protected override string ToExpression() => $"!{Label}"; } diff --git a/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs b/src/KubeOps.KubernetesClient/Selectors/SelectorExtensions.cs similarity index 55% rename from src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs rename to src/KubeOps.KubernetesClient/Selectors/SelectorExtensions.cs index 7f564c13..f7831861 100644 --- a/src/KubeOps.KubernetesClient/LabelSelectors/Extensions.cs +++ b/src/KubeOps.KubernetesClient/Selectors/SelectorExtensions.cs @@ -2,9 +2,9 @@ // 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.KubernetesClient.LabelSelectors; +namespace KubeOps.KubernetesClient.Selectors; -public static class Extensions +public static class SelectorExtensions { /// /// Convert an enumerable list of s to a string. @@ -13,4 +13,12 @@ public static class Extensions /// A comma-joined string with all selectors converted to their expressions. public static string ToExpression(this IEnumerable selectors) => string.Join(",", selectors.Select(x => (string)x)); + + /// + /// Convert an enumerable list of s to a string. + /// + /// The list of selectors. + /// A comma-joined string with all selectors converted to their expressions. + public static string ToExpression(this IEnumerable selectors) => + string.Join(",", selectors.Select(x => (string)x)); } diff --git a/src/KubeOps.Operator/Builder/OperatorBuilder.cs b/src/KubeOps.Operator/Builder/OperatorBuilder.cs index 825ab609..66f57850 100644 --- a/src/KubeOps.Operator/Builder/OperatorBuilder.cs +++ b/src/KubeOps.Operator/Builder/OperatorBuilder.cs @@ -74,7 +74,7 @@ public IOperatorBuilder AddController() return this; } - public IOperatorBuilder AddController() + public IOperatorBuilder AddControllerWithLabelSelector() where TImplementation : class, IEntityController where TEntity : IKubernetesObject where TLabelSelector : class, IEntityLabelSelector @@ -85,6 +85,17 @@ public IOperatorBuilder AddController( return this; } + public IOperatorBuilder AddControllerWithFieldSelector() + where TImplementation : class, IEntityController + where TEntity : IKubernetesObject + where TFieldSelector : class, IEntityFieldSelector + { + AddController(); + Services.TryAddSingleton, TFieldSelector>(); + + return this; + } + public IOperatorBuilder AddFinalizer(string identifier) where TImplementation : class, IEntityFinalizer where TEntity : IKubernetesObject @@ -136,6 +147,7 @@ private void AddOperatorBase() services.GetRequiredService().Create()); Services.AddSingleton(typeof(IEntityLabelSelector<>), typeof(DefaultEntityLabelSelector<>)); + Services.AddSingleton(typeof(IEntityFieldSelector<>), typeof(DefaultEntityFieldSelector<>)); if (Settings.LeaderElectionType == LeaderElectionType.Single) { diff --git a/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs b/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs index a5ff965b..f1101e39 100644 --- a/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs +++ b/src/KubeOps.Operator/Watcher/LeaderAwareResourceWatcher{TEntity}.cs @@ -27,6 +27,7 @@ public class LeaderAwareResourceWatcher( ITimedEntityQueue entityQueue, OperatorSettings settings, IEntityLabelSelector labelSelector, + IEntityFieldSelector fieldSelector, IKubernetesClient client, IHostApplicationLifetime hostApplicationLifetime, LeaderElector elector) @@ -37,6 +38,7 @@ public class LeaderAwareResourceWatcher( entityQueue, settings, labelSelector, + fieldSelector, client) where TEntity : IKubernetesObject { diff --git a/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs b/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs index d2d4010d..9db5ed9d 100644 --- a/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs +++ b/src/KubeOps.Operator/Watcher/ResourceWatcher{TEntity}.cs @@ -33,6 +33,7 @@ public class ResourceWatcher( ITimedEntityQueue entityQueue, OperatorSettings settings, IEntityLabelSelector labelSelector, + IEntityFieldSelector fieldSelector, IKubernetesClient client) : IHostedService, IAsyncDisposable, IDisposable where TEntity : IKubernetesObject @@ -252,6 +253,7 @@ private async Task WatchClientEventsAsync(CancellationToken stoppingToken) settings.Namespace, resourceVersion: currentVersion, labelSelector: await labelSelector.GetLabelSelectorAsync(stoppingToken), + fieldSelector: await fieldSelector.GetFieldSelectorAsync(stoppingToken), allowWatchBookmarks: true, cancellationToken: stoppingToken)) { diff --git a/test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs b/test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs deleted file mode 100644 index 2bcf547e..00000000 --- a/test/KubeOps.KubernetesClient.Test/LabelSelector.Test.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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. - -using KubeOps.KubernetesClient.LabelSelectors; - -namespace KubeOps.KubernetesClient.Test; - -public class LabelSelectorTest : IntegrationTestBase -{ - [Fact] - public void Sould_Return_Correct_Expression() - { - var labelSelectors = new LabelSelector[] { - new EqualsSelector("app", Enumerable.Range(0,3).Select(x=>$"app-{x}").ToArray()), - new NotEqualsSelector("srv", Enumerable.Range(0,2).Select(x=>$"service-{x}").ToArray()) - }; - - string expected = "app in (app-0,app-1,app-2),srv notin (service-0,service-1)"; - var actual = labelSelectors.ToExpression(); - Assert.Equal(expected, actual); - } -} diff --git a/test/KubeOps.KubernetesClient.Test/Selectors/FieldSelector.Test.cs b/test/KubeOps.KubernetesClient.Test/Selectors/FieldSelector.Test.cs new file mode 100644 index 00000000..736d0188 --- /dev/null +++ b/test/KubeOps.KubernetesClient.Test/Selectors/FieldSelector.Test.cs @@ -0,0 +1,42 @@ +// 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. + +using FluentAssertions; + +using KubeOps.KubernetesClient.Selectors; + +namespace KubeOps.KubernetesClient.Test; + +public sealed class FieldSelectorTest : IntegrationTestBase +{ + [Fact] + public void Should_Return_Correct_Expression_For_Equals() + { + var selector = new EqualsFieldSelector("metadata.name", "my-resource"); + string actual = selector; + actual.Should().Be("metadata.name=my-resource"); + } + + [Fact] + public void Should_Return_Correct_Expression_For_NotEquals() + { + var selector = new NotEqualsFieldSelector("metadata.namespace", "kube-system"); + string actual = selector; + actual.Should().Be("metadata.namespace!=kube-system"); + } + + [Fact] + public void Should_Return_Correct_Combined_Expression() + { + var fieldSelectors = new FieldSelector[] + { + new EqualsFieldSelector("metadata.name", "my-resource"), + new NotEqualsFieldSelector("metadata.namespace", "kube-system"), + }; + + const string expected = "metadata.name=my-resource,metadata.namespace!=kube-system"; + var actual = fieldSelectors.ToExpression(); + actual.Should().Be(expected); + } +} diff --git a/test/KubeOps.KubernetesClient.Test/Selectors/LabelSelector.Test.cs b/test/KubeOps.KubernetesClient.Test/Selectors/LabelSelector.Test.cs new file mode 100644 index 00000000..915e94c8 --- /dev/null +++ b/test/KubeOps.KubernetesClient.Test/Selectors/LabelSelector.Test.cs @@ -0,0 +1,23 @@ +// 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. + +using KubeOps.KubernetesClient.Selectors; + +namespace KubeOps.KubernetesClient.Test; + +public sealed class LabelSelectorTest : IntegrationTestBase +{ + [Fact] + public void Should_Return_Correct_Expression() + { + var labelSelectors = new LabelSelector[] { + new EqualsLabelSelector("app", Enumerable.Range(0,3).Select(x=>$"app-{x}").ToArray()), + new NotEqualsLabelSelector("srv", Enumerable.Range(0,2).Select(x=>$"service-{x}").ToArray()) + }; + + const string expected = "app in (app-0,app-1,app-2),srv notin (service-0,service-1)"; + var actual = labelSelectors.ToExpression(); + Assert.Equal(expected, actual); + } +} diff --git a/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs b/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs index 3028ed24..5f719e1f 100644 --- a/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs +++ b/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs @@ -11,7 +11,7 @@ using KubeOps.Abstractions.Reconciliation.Controller; using KubeOps.Abstractions.Reconciliation.Finalizer; using KubeOps.Abstractions.Reconciliation.Queue; -using KubeOps.KubernetesClient.LabelSelectors; +using KubeOps.KubernetesClient.Selectors; using KubeOps.Operator.Builder; using KubeOps.Operator.Queue; using KubeOps.Operator.Test.TestEntities; @@ -40,6 +40,10 @@ public void Should_Add_Default_Resources() s.ServiceType == typeof(IEntityLabelSelector<>) && s.ImplementationType == typeof(DefaultEntityLabelSelector<>) && s.Lifetime == ServiceLifetime.Singleton); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(IEntityFieldSelector<>) && + s.ImplementationType == typeof(DefaultEntityFieldSelector<>) && + s.Lifetime == ServiceLifetime.Singleton); } [Fact] @@ -82,7 +86,7 @@ public void Should_Add_Controller_Resources() [Fact] public void Should_Add_Controller_Resources_With_Label_Selector() { - _builder.AddController(); + _builder.AddControllerWithLabelSelector(); _builder.Services.Should().Contain(s => s.ServiceType == typeof(IEntityController) && @@ -118,6 +122,21 @@ public void Should_Add_Finalizer_Resources() s.Lifetime == ServiceLifetime.Transient); } + [Fact] + public void Should_Add_Controller_Resources_With_Field_Selector() + { + _builder.AddControllerWithFieldSelector(); + + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(IEntityController) && + s.ImplementationType == typeof(TestController) && + s.Lifetime == ServiceLifetime.Scoped); + _builder.Services.Should().Contain(s => + s.ServiceType == typeof(IEntityFieldSelector) && + s.ImplementationType == typeof(TestFieldSelector) && + s.Lifetime == ServiceLifetime.Singleton); + } + [Fact] public void Should_Add_Leader_Elector() { @@ -164,10 +183,16 @@ private sealed class TestLabelSelector : IEntityLabelSelector(labelSelectors.ToExpression()); } } + + private sealed class TestFieldSelector : IEntityFieldSelector + { + public ValueTask GetFieldSelectorAsync(CancellationToken cancellationToken) => + ValueTask.FromResult("metadata.name=my-resource"); + } } diff --git a/test/KubeOps.Operator.Test/Watcher/LeaderAwareResourceWatcher.Test.cs b/test/KubeOps.Operator.Test/Watcher/LeaderAwareResourceWatcher.Test.cs index 87539987..0b212424 100644 --- a/test/KubeOps.Operator.Test/Watcher/LeaderAwareResourceWatcher.Test.cs +++ b/test/KubeOps.Operator.Test/Watcher/LeaderAwareResourceWatcher.Test.cs @@ -87,6 +87,7 @@ public TestableLeaderAwareResourceWatcher( queue, new OperatorSettingsBuilder { Namespace = "unit-test" }.Build(), new DefaultEntityLabelSelector(), + new DefaultEntityFieldSelector(), client, lifetime, elector) diff --git a/test/KubeOps.Operator.Test/Watcher/ResourceWatcher{TEntity}.Test.cs b/test/KubeOps.Operator.Test/Watcher/ResourceWatcher{TEntity}.Test.cs index 99247449..3be02320 100644 --- a/test/KubeOps.Operator.Test/Watcher/ResourceWatcher{TEntity}.Test.cs +++ b/test/KubeOps.Operator.Test/Watcher/ResourceWatcher{TEntity}.Test.cs @@ -49,6 +49,7 @@ public async Task Restarting_Watcher_Should_Trigger_New_Watch() "unit-test", null, null, + null, true, It.IsAny()), Times.Exactly(2)); @@ -489,6 +490,7 @@ private static TestableResourceWatcher CreateTestableWatcher( var fCache = cache ?? Mock.Of(); var timedEntityQueue = queue ?? Mock.Of>(); var labelSelector = new DefaultEntityLabelSelector(); + var fieldSelector = new DefaultEntityFieldSelector(); // If a fully configured cacheProvider is passed, use it directly. // Otherwise build a default mock that returns fCache for any cache name. @@ -503,8 +505,8 @@ private static TestableResourceWatcher CreateTestableWatcher( if (waitForCancellation) { Mock.Get(kubeClient) - .Setup(client => client.WatchAsync("unit-test", null, null, true, It.IsAny())) - .Returns((_, _, _, _, cancellationToken) => WaitForCancellationAsync<(WatchEventType, V1Pod)>(cancellationToken)); + .Setup(client => client.WatchAsync("unit-test", null, null, null, true, It.IsAny())) + .Returns((_, _, _, _, _, cancellationToken) => WaitForCancellationAsync<(WatchEventType, V1Pod)>(cancellationToken)); } return new( @@ -514,6 +516,7 @@ private static TestableResourceWatcher CreateTestableWatcher( timedEntityQueue, effectiveSettings, labelSelector, + fieldSelector, kubeClient); } @@ -530,8 +533,9 @@ private sealed class TestableResourceWatcher( ITimedEntityQueue queue, OperatorSettings settings, IEntityLabelSelector labelSelector, + IEntityFieldSelector fieldSelector, IKubernetesClient client) - : ResourceWatcher(activitySource, logger, cacheProvider, queue, settings, labelSelector, client) + : ResourceWatcher(activitySource, logger, cacheProvider, queue, settings, labelSelector, fieldSelector, client) { public Task InvokeOnEventAsync(WatchEventType eventType, V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => OnEventAsync(eventType, entity, cancellationToken);