diff --git a/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs b/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs index e650b76a2..a3861dd57 100644 --- a/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs +++ b/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs @@ -40,6 +40,17 @@ namespace KubeOps.Abstractions.Reconciliation.Controller; public interface IEntityController where TEntity : IKubernetesObject { + /// + /// An optional Kubernetes label selector expression (e.g. app in (foo,bar),env in (prod)) that + /// restricts which entities this controller handles. When null (the default) the controller + /// handles all entities of the given type, preserving backward-compatible behaviour. + /// + /// When multiple controllers are registered for the same entity type the reconciler evaluates every + /// controller's against the entity's labels and dispatches to all that + /// match, allowing fine-grained fan-out without touching the watcher or DI registration plumbing. + /// + string? LabelFilter => null; + /// /// Reconciles the state of the specified entity with the desired state. /// This method is triggered for `added` and `modified` events from the watcher. diff --git a/src/KubeOps.Operator/Builder/OperatorBuilder.cs b/src/KubeOps.Operator/Builder/OperatorBuilder.cs index b7e7be17f..c5a28a46e 100644 --- a/src/KubeOps.Operator/Builder/OperatorBuilder.cs +++ b/src/KubeOps.Operator/Builder/OperatorBuilder.cs @@ -46,7 +46,7 @@ public IOperatorBuilder AddController() where TImplementation : class, IEntityController where TEntity : IKubernetesObject { - Services.TryAddScoped, TImplementation>(); + Services.AddScoped, TImplementation>(); Services.TryAddSingleton, Reconciler>(); // Requeue diff --git a/src/KubeOps.Operator/Reconciliation/LabelSelectorMatcher.cs b/src/KubeOps.Operator/Reconciliation/LabelSelectorMatcher.cs new file mode 100644 index 000000000..e4ee311f0 --- /dev/null +++ b/src/KubeOps.Operator/Reconciliation/LabelSelectorMatcher.cs @@ -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; + +/// +/// Evaluates a Kubernetes label-selector expression against an entity's label dictionary. +/// Supports the set-based formats emitted by KubeOps.KubernetesClient label-selector types: +/// +/// key in (v1,v2) – EqualsSelector +/// key notin (v1,v2) – NotEqualsSelector +/// key – ExistsSelector +/// !key – NotExistsSelector +/// +/// Multiple clauses joined by commas are evaluated as AND. +/// +internal static class LabelSelectorMatcher +{ + internal static bool Matches(string? selector, IReadOnlyDictionary? entityLabels) + { + if (selector is null) return true; + entityLabels ??= new Dictionary(); + + return SplitTopLevel(selector).All(clause => MatchClause(clause, entityLabels)); + } + + // Splits "key in (a,b),other notin (c)" at top-level commas only (ignores commas inside parens). + private static IEnumerable SplitTopLevel(string selector) + { + int depth = 0, start = 0; + for (int i = 0; i < selector.Length; i++) + { + switch (selector[i]) + { + case '(': + depth++; + break; + case ')': + depth--; + break; + case ',' when depth == 0: + yield return selector[start..i].Trim(); + start = i + 1; + break; + } + } + + if (start < selector.Length) + { + yield return selector[start..].Trim(); + } + } + + private static bool MatchClause(string clause, IReadOnlyDictionary labels) + { + const string inOp = " in ("; + const string notinOp = " notin ("; + + // "key in (v1,v2)" + int idx = clause.IndexOf(inOp, StringComparison.Ordinal); + if (idx >= 0 && clause.EndsWith(')')) + { + var key = clause[..idx].Trim(); + var values = ParseValues(clause[(idx + inOp.Length)..^1]); + return labels.TryGetValue(key, out var v) && values.Contains(v); + } + + // "key notin (v1,v2)" + idx = clause.IndexOf(notinOp, StringComparison.Ordinal); + if (idx >= 0 && clause.EndsWith(')')) + { + var key = clause[..idx].Trim(); + var values = ParseValues(clause[(idx + notinOp.Length)..^1]); + return !labels.TryGetValue(key, out var v) || !values.Contains(v); + } + + // "key!=value" + int neqIdx = clause.IndexOf("!=", StringComparison.Ordinal); + if (neqIdx >= 0) + { + var key = clause[..neqIdx].Trim(); + var value = clause[(neqIdx + 2)..].Trim(); + return !labels.TryGetValue(key, out var v) || v != value; + } + + // "key=value" + int eqIdx = clause.IndexOf('='); + if (eqIdx >= 0) + { + var key = clause[..eqIdx].Trim(); + var value = clause[(eqIdx + 1)..].Trim(); + return labels.TryGetValue(key, out var v) && v == value; + } + + // "!key" + if (clause.StartsWith('!')) + { + return !labels.ContainsKey(clause[1..].Trim()); + } + + // "key" + return labels.ContainsKey(clause); + } + + private static HashSet ParseValues(string csv) => + csv.Split(',').Select(v => v.Trim()).ToHashSet(StringComparer.Ordinal); +} diff --git a/src/KubeOps.Operator/Reconciliation/Reconciler.cs b/src/KubeOps.Operator/Reconciliation/Reconciler.cs index 513c5195d..32c754d18 100644 --- a/src/KubeOps.Operator/Reconciliation/Reconciler.cs +++ b/src/KubeOps.Operator/Reconciliation/Reconciler.cs @@ -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>(); - 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>(); - return await controller.ReconcileAsync(entity, cancellationToken); + return await DispatchToMatchingControllers( + scope.ServiceProvider, + entity, + (ctrl, e, ct) => ctrl.ReconcileAsync(e, ct), + cancellationToken); + } + + /// + /// Gets all registrations whose + /// matches the given entity's labels, then calls 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. + /// + private async Task> DispatchToMatchingControllers( + IServiceProvider services, + TEntity entity, + Func, TEntity, CancellationToken, Task>> operation, + CancellationToken cancellationToken) + { + var entityLabels = (IReadOnlyDictionary?)entity.Labels() + ?? new Dictionary(); + + var controllers = services + .GetServices>() + .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.Success(entity); + } + + ReconciliationResult result = ReconciliationResult.Success(entity); + foreach (var controller in controllers) + { + result = await operation(controller, result.Entity, cancellationToken); + if (!result.IsSuccess) return result; + } + + return result; } private async Task> ReconcileFinalizersSequential(TEntity entity, CancellationToken cancellationToken) diff --git a/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs b/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs index 105b024cc..12c9343f9 100644 --- a/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs +++ b/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs @@ -127,6 +127,53 @@ public void Should_Add_Leader_Elector() s.Lifetime == ServiceLifetime.Singleton); } + [Fact] + public void Should_Allow_Multiple_Controllers_For_Same_Entity_Type() + { + _builder.AddController(); + _builder.AddController(); + + var registrations = _builder.Services + .Where(s => + s.ServiceType == typeof(IEntityController) && + s.Lifetime == ServiceLifetime.Scoped) + .ToList(); + + registrations.Should().HaveCount(2); + registrations.Should().Contain(s => s.ImplementationType == typeof(TestController)); + registrations.Should().Contain(s => s.ImplementationType == typeof(SecondTestController)); + } + + [Fact] + public void Should_Resolve_All_Controllers_For_Same_Entity_Type() + { + _builder.AddController(); + _builder.AddController(); + + var provider = _builder.Services.BuildServiceProvider(); + var controllers = provider + .GetServices>() + .ToList(); + + controllers.Should().HaveCount(2); + controllers.Should().ContainItemsAssignableTo>(); + controllers.Select(c => c.GetType()).Should().Contain(typeof(TestController)); + controllers.Select(c => c.GetType()).Should().Contain(typeof(SecondTestController)); + } + + [Fact] + public void Should_Not_Register_Duplicate_ResourceWatcher_For_Multiple_Controllers() + { + _builder.AddController(); + _builder.AddController(); + + _builder.Services + .Where(s => + s.ServiceType == typeof(IHostedService) && + s.ImplementationType == typeof(ResourceWatcher)) + .Should().HaveCount(1); + } + [Fact] public void Should_Add_LeaderAwareResourceWatcher() { @@ -152,6 +199,15 @@ public Task> DeletedAsync( Task.FromResult(ReconciliationResult.Success(entity)); } + private sealed class SecondTestController : IEntityController + { + public Task> ReconcileAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => + Task.FromResult(ReconciliationResult.Success(entity)); + + public Task> DeletedAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => + Task.FromResult(ReconciliationResult.Success(entity)); + } + private sealed class TestFinalizer : IEntityFinalizer { public Task> FinalizeAsync(V1OperatorIntegrationTestEntity entity, CancellationToken cancellationToken) => diff --git a/test/KubeOps.Operator.Test/Reconciliation/LabelSelectorMatcher.Test.cs b/test/KubeOps.Operator.Test/Reconciliation/LabelSelectorMatcher.Test.cs new file mode 100644 index 000000000..cdfc81f71 --- /dev/null +++ b/test/KubeOps.Operator.Test/Reconciliation/LabelSelectorMatcher.Test.cs @@ -0,0 +1,252 @@ +// 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.Operator.Reconciliation; + +namespace KubeOps.Operator.Test.Reconciliation; + +public sealed class LabelSelectorMatcherTest +{ + // ── null selector ──────────────────────────────────────────────────────── + + [Fact] + public void Matches_NullSelector_ReturnsTrue() + { + var labels = new Dictionary { ["env"] = "prod" }; + LabelSelectorMatcher.Matches(null, labels).Should().BeTrue(); + } + + [Fact] + public void Matches_NullSelector_EmptyLabels_ReturnsTrue() + { + LabelSelectorMatcher.Matches(null, new Dictionary()).Should().BeTrue(); + } + + [Fact] + public void Matches_NullSelector_NullLabels_ReturnsTrue() + { + LabelSelectorMatcher.Matches(null, null).Should().BeTrue(); + } + + // ── "key in (v1,v2)" ───────────────────────────────────────────────────── + + [Fact] + public void Matches_InOperator_KeyPresentWithMatchingValue_ReturnsTrue() + { + var labels = new Dictionary { ["env"] = "prod" }; + LabelSelectorMatcher.Matches("env in (prod)", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_InOperator_KeyPresentAmongMultipleValues_ReturnsTrue() + { + var labels = new Dictionary { ["env"] = "staging" }; + LabelSelectorMatcher.Matches("env in (prod,staging,dev)", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_InOperator_KeyPresentButWrongValue_ReturnsFalse() + { + var labels = new Dictionary { ["env"] = "dev" }; + LabelSelectorMatcher.Matches("env in (prod,staging)", labels).Should().BeFalse(); + } + + [Fact] + public void Matches_InOperator_KeyAbsent_ReturnsFalse() + { + var labels = new Dictionary { ["tier"] = "frontend" }; + LabelSelectorMatcher.Matches("env in (prod)", labels).Should().BeFalse(); + } + + // ── "key notin (v1,v2)" ────────────────────────────────────────────────── + + [Fact] + public void Matches_NotInOperator_KeyAbsent_ReturnsTrue() + { + var labels = new Dictionary { ["tier"] = "frontend" }; + LabelSelectorMatcher.Matches("env notin (prod)", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_NotInOperator_KeyPresentButNotInValues_ReturnsTrue() + { + var labels = new Dictionary { ["env"] = "dev" }; + LabelSelectorMatcher.Matches("env notin (prod,staging)", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_NotInOperator_KeyPresentAndInValues_ReturnsFalse() + { + var labels = new Dictionary { ["env"] = "prod" }; + LabelSelectorMatcher.Matches("env notin (prod,staging)", labels).Should().BeFalse(); + } + + // ── "key" (exists) ──────────────────────────────────────────────────────── + + [Fact] + public void Matches_ExistsOperator_KeyPresent_ReturnsTrue() + { + var labels = new Dictionary { ["managed"] = "true" }; + LabelSelectorMatcher.Matches("managed", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_ExistsOperator_KeyAbsent_ReturnsFalse() + { + var labels = new Dictionary { ["env"] = "prod" }; + LabelSelectorMatcher.Matches("managed", labels).Should().BeFalse(); + } + + // ── "!key" (not exists) ─────────────────────────────────────────────────── + + [Fact] + public void Matches_NotExistsOperator_KeyAbsent_ReturnsTrue() + { + var labels = new Dictionary { ["env"] = "prod" }; + LabelSelectorMatcher.Matches("!managed", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_NotExistsOperator_KeyPresent_ReturnsFalse() + { + var labels = new Dictionary { ["managed"] = "true" }; + LabelSelectorMatcher.Matches("!managed", labels).Should().BeFalse(); + } + + // ── multi-clause (AND semantics) ───────────────────────────────────────── + + [Fact] + public void Matches_MultiClause_AllMatch_ReturnsTrue() + { + var labels = new Dictionary + { + ["env"] = "prod", + ["managed"] = "true", + }; + LabelSelectorMatcher.Matches("env in (prod),managed in (true)", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_MultiClause_OneClauseDoesNotMatch_ReturnsFalse() + { + var labels = new Dictionary + { + ["env"] = "prod", + ["managed"] = "false", + }; + LabelSelectorMatcher.Matches("env in (prod),managed in (true)", labels).Should().BeFalse(); + } + + [Fact] + public void Matches_MultiClause_InAndNotIn_ReturnsTrue() + { + var labels = new Dictionary + { + ["env"] = "prod", + }; + LabelSelectorMatcher.Matches("env in (prod),!managed", labels).Should().BeTrue(); + } + + // ── commas inside parentheses must NOT be treated as clause separators ─── + + [Fact] + public void Matches_InOperatorWithCommaInValues_DoesNotSplitAtInnerComma() + { + // "env in (prod,staging)" has a comma inside parens — must be treated as ONE clause + var labels = new Dictionary { ["env"] = "staging" }; + LabelSelectorMatcher.Matches("env in (prod,staging)", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_ComplexMultiClauseWithCommaInsideParens_CorrectlyEvaluated() + { + var labels = new Dictionary + { + ["env"] = "prod", + ["region"] = "eu-west", + }; + LabelSelectorMatcher.Matches( + "env in (prod,staging),region notin (us-east,us-west)", + labels).Should().BeTrue(); + } + + // ── empty labels ───────────────────────────────────────────────────────── + + [Fact] + public void Matches_ExistsClause_EmptyLabels_ReturnsFalse() + { + LabelSelectorMatcher.Matches("env", new Dictionary()).Should().BeFalse(); + } + + [Fact] + public void Matches_NotExistsClause_EmptyLabels_ReturnsTrue() + { + LabelSelectorMatcher.Matches("!env", new Dictionary()).Should().BeTrue(); + } + + [Fact] + public void Matches_NotInClause_EmptyLabels_ReturnsTrue() + { + // key absent → not in any set → true + LabelSelectorMatcher.Matches("env notin (prod)", new Dictionary()).Should().BeTrue(); + } + + // ── key=value equality ──────────────────────────────────────────────────── + + [Fact] + public void Matches_EqualityOperator_Matches_ReturnsTrue() + { + var labels = new Dictionary { ["env"] = "prod" }; + LabelSelectorMatcher.Matches("env=prod", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_EqualityOperator_WrongValue_ReturnsFalse() + { + var labels = new Dictionary { ["env"] = "staging" }; + LabelSelectorMatcher.Matches("env=prod", labels).Should().BeFalse(); + } + + [Fact] + public void Matches_EqualityOperator_KeyAbsent_ReturnsFalse() + { + LabelSelectorMatcher.Matches("env=prod", new Dictionary()).Should().BeFalse(); + } + + // ── key!=value inequality ───────────────────────────────────────────────── + + [Fact] + public void Matches_InequalityOperator_DifferentValue_ReturnsTrue() + { + var labels = new Dictionary { ["env"] = "staging" }; + LabelSelectorMatcher.Matches("env!=prod", labels).Should().BeTrue(); + } + + [Fact] + public void Matches_InequalityOperator_SameValue_ReturnsFalse() + { + var labels = new Dictionary { ["env"] = "prod" }; + LabelSelectorMatcher.Matches("env!=prod", labels).Should().BeFalse(); + } + + [Fact] + public void Matches_InequalityOperator_KeyAbsent_ReturnsTrue() + { + // key absent → not equal → true + LabelSelectorMatcher.Matches("env!=prod", new Dictionary()).Should().BeTrue(); + } + + [Fact] + public void Matches_MixedEqualityAndSetBased_AllMatch_ReturnsTrue() + { + var labels = new Dictionary + { + ["env"] = "prod", + ["region"] = "eu-west", + }; + LabelSelectorMatcher.Matches("env=prod,region in (eu-west,us-east)", labels).Should().BeTrue(); + } +} diff --git a/test/KubeOps.Operator.Test/Reconciliation/Reconciler.MultiController.Test.cs b/test/KubeOps.Operator.Test/Reconciliation/Reconciler.MultiController.Test.cs new file mode 100644 index 000000000..a34b096e5 --- /dev/null +++ b/test/KubeOps.Operator.Test/Reconciliation/Reconciler.MultiController.Test.cs @@ -0,0 +1,310 @@ +// 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 k8s; +using k8s.Models; + +using KubeOps.Abstractions.Builder; +using KubeOps.Abstractions.Reconciliation; +using KubeOps.Abstractions.Reconciliation.Controller; +using KubeOps.KubernetesClient; +using KubeOps.Operator.Queue; +using KubeOps.Operator.Reconciliation; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Moq; + +using ZiggyCreatures.Caching.Fusion; + +namespace KubeOps.Operator.Test.Reconciliation; + +/// +/// Tests for the multi-controller dispatch logic in . +/// Verifies that is respected when +/// multiple controllers are registered for the same entity type. +/// +public sealed class ReconcilerMultiControllerTest +{ + private readonly Mock>> _mockLogger = new(); + private readonly Mock _mockCacheProvider = new(); + private readonly Mock _mockCache = new(); + private readonly Mock _mockServiceProvider = new(); + private readonly Mock _mockClient = new(); + private readonly Mock> _mockQueue = new(); + private readonly OperatorSettings _settings = new() { AutoAttachFinalizers = false, AutoDetachFinalizers = false }; + + public ReconcilerMultiControllerTest() + { + _mockCacheProvider + .Setup(p => p.GetCache(It.IsAny())) + .Returns(_mockCache.Object); + } + + // ── null LabelFilter = catch-all ────────────────────────────────────────── + + [Fact] + public async Task Dispatch_NullLabelFilter_MatchesEntityWithNoLabels() + { + var entity = CreateEntity(); + var controller = CreateController(labelFilter: null); + var reconciler = CreateReconciler([controller.Object]); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + controller.Verify(c => c.ReconcileAsync(entity, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Dispatch_NullLabelFilter_MatchesEntityWithLabels() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = CreateController(labelFilter: null); + var reconciler = CreateReconciler([controller.Object]); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + controller.Verify(c => c.ReconcileAsync(entity, It.IsAny()), Times.Once); + } + + // ── label filter matching ───────────────────────────────────────────────── + + [Fact] + public async Task Dispatch_LabelFilterMatches_ControllerIsCalled() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = CreateController(labelFilter: "env in (prod)"); + var reconciler = CreateReconciler([controller.Object]); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + controller.Verify(c => c.ReconcileAsync(entity, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Dispatch_LabelFilterDoesNotMatch_ControllerIsNotCalled() + { + var entity = CreateEntity(labels: new() { ["env"] = "staging" }); + var controller = CreateController(labelFilter: "env in (prod)"); + var reconciler = CreateReconciler([controller.Object]); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + controller.Verify(c => c.ReconcileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_EntityHasNoLabels_FilteredControllerIsNotCalled() + { + var entity = CreateEntity(); + var controller = CreateController(labelFilter: "env in (prod)"); + var reconciler = CreateReconciler([controller.Object]); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + controller.Verify(c => c.ReconcileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ── multiple controllers ────────────────────────────────────────────────── + + [Fact] + public async Task Dispatch_TwoMatchingControllers_BothCalledInOrder() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var callOrder = new List(); + + var ctrl1 = CreateController(labelFilter: "env in (prod)", onReconcile: e => + { + callOrder.Add(1); + return ReconciliationResult.Success(e); + }); + var ctrl2 = CreateController(labelFilter: null, onReconcile: e => + { + callOrder.Add(2); + return ReconciliationResult.Success(e); + }); + + var reconciler = CreateReconciler([ctrl1.Object, ctrl2.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + callOrder.Should().Equal(1, 2); + } + + [Fact] + public async Task Dispatch_TwoControllers_OnlyMatchingOneIsCalled() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var prodCtrl = CreateController(labelFilter: "env in (prod)"); + var stagingCtrl = CreateController(labelFilter: "env in (staging)"); + + var reconciler = CreateReconciler([prodCtrl.Object, stagingCtrl.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + prodCtrl.Verify(c => c.ReconcileAsync(entity, It.IsAny()), Times.Once); + stagingCtrl.Verify(c => c.ReconcileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_FirstControllerFails_SecondControllerNotCalled() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var failResult = ReconciliationResult.Failure(entity, "first failed"); + + var ctrl1 = CreateController(labelFilter: null, onReconcile: _ => failResult); + var ctrl2 = CreateController(labelFilter: null); + + var reconciler = CreateReconciler([ctrl1.Object, ctrl2.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + var result = await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeFalse(); + result.ErrorMessage.Should().Be("first failed"); + ctrl2.Verify(c => c.ReconcileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_NoControllerMatches_ReturnsSuccess() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = CreateController(labelFilter: "env in (staging)"); + + var reconciler = CreateReconciler([controller.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + var result = await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Dispatch_NoControllerMatches_LogsWarning() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = CreateController(labelFilter: "env in (staging)"); + + var reconciler = CreateReconciler([controller.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + _mockLogger.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, _) => o.ToString()!.Contains(entity.Name())), + null, + It.IsAny>()), + Times.Once); + } + + // ── entity is passed through the chain ──────────────────────────────────── + + [Fact] + public async Task Dispatch_ControllerMutatesEntity_NextControllerReceivesMutatedEntity() + { + var original = CreateEntity(); + var mutated = CreateEntity(name: "mutated-configmap"); + + var ctrl1 = CreateController(labelFilter: null, onReconcile: _ => + ReconciliationResult.Success(mutated)); + + V1ConfigMap? receivedByCtrl2 = null; + var ctrl2 = CreateController(labelFilter: null, onReconcile: e => + { + receivedByCtrl2 = e; + return ReconciliationResult.Success(e); + }); + + var reconciler = CreateReconciler([ctrl1.Object, ctrl2.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(original, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + receivedByCtrl2.Should().BeSameAs(mutated); + } + + // ── DeletedAsync path ──────────────────────────────────────────────────── + + [Fact] + public async Task Dispatch_DeletedEvent_MatchingControllerDeletedAsyncCalled() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = CreateController(labelFilter: "env in (prod)"); + + var reconciler = CreateReconciler([controller.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Deleted); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + controller.Verify(c => c.DeletedAsync(entity, It.IsAny()), Times.Once); + controller.Verify(c => c.ReconcileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private Reconciler CreateReconciler(IList> controllers) + { + var mockScope = new Mock(); + var mockScopeFactory = new Mock(); + + mockScope.Setup(s => s.ServiceProvider).Returns(_mockServiceProvider.Object); + mockScopeFactory.Setup(s => s.CreateScope()).Returns(mockScope.Object); + + _mockServiceProvider + .Setup(p => p.GetService(typeof(IServiceScopeFactory))) + .Returns(mockScopeFactory.Object); + + _mockServiceProvider + .Setup(p => p.GetService(typeof(IEnumerable>))) + .Returns(controllers); + + return new( + _mockLogger.Object, + _mockCacheProvider.Object, + _mockServiceProvider.Object, + _settings, + _mockQueue.Object, + _mockClient.Object); + } + + private static Mock> CreateController( + string? labelFilter, + Func>? onReconcile = null) + { + var mock = new Mock>(); + + mock.Setup(c => c.LabelFilter).Returns(labelFilter); + + mock.Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((V1ConfigMap e, CancellationToken _) => + onReconcile?.Invoke(e) ?? ReconciliationResult.Success(e)); + + mock.Setup(c => c.DeletedAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((V1ConfigMap e, CancellationToken _) => + ReconciliationResult.Success(e)); + + return mock; + } + + private static V1ConfigMap CreateEntity( + string? name = null, + Dictionary? labels = null) => + new() + { + Metadata = new() + { + Name = name ?? "test-configmap", + NamespaceProperty = "default", + Uid = Guid.NewGuid().ToString(), + Generation = 1, + Labels = labels, + }, + Kind = V1ConfigMap.KubeKind, + }; +} diff --git a/test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs b/test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs index 32632d46f..928f164c0 100644 --- a/test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs +++ b/test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs @@ -471,8 +471,8 @@ private Reconciler CreateReconcilerForController(IEntityController< .Returns(mockScopeFactory.Object); _mockServiceProvider - .Setup(p => p.GetService(typeof(IEntityController))) - .Returns(controller); + .Setup(p => p.GetService(typeof(IEnumerable>))) + .Returns(new List> { controller }); return new( _mockLogger.Object,