diff --git a/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs b/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs index e650b76a2..baa230919 100644 --- a/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs +++ b/src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs @@ -40,6 +40,20 @@ namespace KubeOps.Abstractions.Reconciliation.Controller; public interface IEntityController where TEntity : IKubernetesObject { + /// + /// Returns true when this controller is responsible for the given entity. + /// The default implementation returns true, preserving single-controller backward-compatible behaviour. + /// + /// When multiple controllers are registered for the same entity type the reconciler asks every + /// controller whether it the entity and dispatches to all that claim + /// responsibility in registration order. Typical use cases: filtering by labels, annotations, + /// namespace, status conditions, or any other entity-derived predicate the consumer needs. + /// + /// The entity the reconciler is about to dispatch. + /// The token used to signal cancellation of the operation. + /// A that resolves to true if this controller should reconcile the entity. + ValueTask ShouldHandle(TEntity entity, CancellationToken cancellationToken = default) => ValueTask.FromResult(true); + /// /// 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.Abstractions/Reconciliation/Finalizer/IEntityFinalizer{TEntity}.cs b/src/KubeOps.Abstractions/Reconciliation/Finalizer/IEntityFinalizer{TEntity}.cs index f3b38983d..ddfcb8405 100644 --- a/src/KubeOps.Abstractions/Reconciliation/Finalizer/IEntityFinalizer{TEntity}.cs +++ b/src/KubeOps.Abstractions/Reconciliation/Finalizer/IEntityFinalizer{TEntity}.cs @@ -14,6 +14,20 @@ namespace KubeOps.Abstractions.Reconciliation.Finalizer; public interface IEntityFinalizer where TEntity : IKubernetesObject { + /// + /// Returns true when this finalizer is responsible for the given entity. + /// The default implementation returns true, preserving backward-compatible behaviour. + /// + /// When is enabled, + /// only finalizers that return true from are attached to the entity. + /// Once attached, the finalizer is dispatched by its identifier as usual — + /// acts as a one-time responsibility claim at attach time, not an ongoing gate. + /// + /// The entity the reconciler is considering for this finalizer. + /// The token to monitor for cancellation requests. + /// A that resolves to true if this finalizer should claim the entity. + ValueTask ShouldHandle(TEntity entity, CancellationToken cancellationToken = default) => ValueTask.FromResult(true); + /// /// Finalize an entity that is pending for deletion. /// diff --git a/src/KubeOps.Operator/Builder/OperatorBuilder.cs b/src/KubeOps.Operator/Builder/OperatorBuilder.cs index b7e7be17f..0a2de43a2 100644 --- a/src/KubeOps.Operator/Builder/OperatorBuilder.cs +++ b/src/KubeOps.Operator/Builder/OperatorBuilder.cs @@ -46,7 +46,10 @@ public IOperatorBuilder AddController() where TImplementation : class, IEntityController where TEntity : IKubernetesObject { - Services.TryAddScoped, TImplementation>(); + // TryAddEnumerable dedupes by (ServiceType, ImplementationType), so calling AddController + // with the same TImplementation twice registers it only once — while still allowing + // distinct implementations to coexist for the same TEntity. + Services.TryAddEnumerable(ServiceDescriptor.Scoped, TImplementation>()); Services.TryAddSingleton, Reconciler>(); // Requeue diff --git a/src/KubeOps.Operator/Reconciliation/Reconciler.cs b/src/KubeOps.Operator/Reconciliation/Reconciler.cs index 513c5195d..763cee1e8 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) { @@ -137,12 +141,20 @@ await entityQueue if (operatorSettings.AutoAttachFinalizers) { + cancellationToken.ThrowIfCancellationRequested(); var finalizers = scope.ServiceProvider.GetKeyedServices>(KeyedService.AnyKey); - var anyFinalizerAdded = finalizers - .Aggregate( - false, - (changed, finalizer) => entity.AddFinalizer(finalizer.GetIdentifierName(entity)) || changed); + var anyFinalizerAdded = false; + foreach (var finalizer in finalizers) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await finalizer.ShouldHandle(entity, cancellationToken)) + { + continue; + } + + anyFinalizerAdded = entity.AddFinalizer(finalizer.GetIdentifierName(entity)) || anyFinalizerAdded; + } if (anyFinalizerAdded) { @@ -150,8 +162,85 @@ 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); + } + + /// + /// Resolves all registrations and, in registration order, + /// asks each controller against the current + /// (possibly mutated) entity just-in-time and dispatches when it claims + /// responsibility. On the first failure the chain short-circuits and that failure is returned. + /// If no controller is registered at all the result is a configuration-error failure; if controllers + /// are registered but none claim responsibility, a success result is returned and a warning is logged. + /// + /// RequeueAfter aggregation: across successful controller results, the earliest non-null + /// is kept, so an auditing controller that + /// returns Success(entity) never erases a requeue requested by an earlier controller. + /// + /// + private async Task> DispatchToMatchingControllers( + IServiceProvider services, + TEntity entity, + Func, TEntity, CancellationToken, Task>> operation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var registeredControllers = services.GetServices>().ToList(); + if (registeredControllers.Count == 0) + { + return ReconciliationResult.Failure( + entity, + $"No IEntityController<{typeof(TEntity).Name}> registered. Did you forget to call AddController() on the operator builder?"); + } + + var currentEntity = entity; + TimeSpan? aggregatedRequeueAfter = null; + var anyDispatched = false; + + foreach (var controller in registeredControllers) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Evaluate ShouldHandle just-in-time against the (possibly mutated) current entity — + // so a controller that would claim the *initial* state but reject the *post-mutation* + // state does not get invoked. + if (!await controller.ShouldHandle(currentEntity, cancellationToken)) + { + continue; + } + + anyDispatched = true; + cancellationToken.ThrowIfCancellationRequested(); + var result = await operation(controller, currentEntity, cancellationToken); + if (!result.IsSuccess) + { + return result; + } + + currentEntity = result.Entity; + + if (result.RequeueAfter is not null && + (aggregatedRequeueAfter is null || result.RequeueAfter < aggregatedRequeueAfter)) + { + aggregatedRequeueAfter = result.RequeueAfter; + } + } + + if (!anyDispatched) + { + logger.LogWarning( + """No responsible controller found for "{Kind}/{Name}". Skipping.""", + currentEntity.Kind, + currentEntity.Name()); + return ReconciliationResult.Success(currentEntity); + } + + return ReconciliationResult.Success(currentEntity, aggregatedRequeueAfter); } 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..8a3b45723 100644 --- a/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs +++ b/test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs @@ -127,6 +127,72 @@ 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_Dedupe_Identical_Controller_Registrations() + { + _builder.AddController(); + _builder.AddController(); + + var registrations = _builder.Services + .Where(s => s.ServiceType == typeof(IEntityController)) + .ToList(); + + registrations.Should().HaveCount(1); + registrations.Should().ContainSingle(s => s.ImplementationType == typeof(TestController)); + } + + [Fact] + public void Should_Resolve_All_Controllers_For_Same_Entity_Type() + { + _builder.AddController(); + _builder.AddController(); + + // Resolve from a scope (with scope validation enabled) to mirror how the runtime reconciler + // dispatches — directly resolving scoped services from the root provider would throw with + // ValidateScopes=true, which is exactly the scenario production hits. + var provider = _builder.Services.BuildServiceProvider( + new ServiceProviderOptions { ValidateScopes = true }); + using var scope = provider.CreateScope(); + var controllers = scope.ServiceProvider + .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 +218,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/LeaderElector/LeaderElectionBackgroundService.Test.cs b/test/KubeOps.Operator.Test/LeaderElector/LeaderElectionBackgroundService.Test.cs index 81d2a5f87..833ec8d36 100644 --- a/test/KubeOps.Operator.Test/LeaderElector/LeaderElectionBackgroundService.Test.cs +++ b/test/KubeOps.Operator.Test/LeaderElector/LeaderElectionBackgroundService.Test.cs @@ -24,7 +24,7 @@ public async Task Elector_Throws_Should_Retry() var electionLock = Mock.Of(); - var electionLockSubsequentCallEvent = new AutoResetEvent(false); + var subsequentCallTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); bool hasElectionLockThrown = false; Mock.Get(electionLock) .Setup(el => el.GetAsync(It.IsAny())) @@ -34,7 +34,7 @@ public async Task Elector_Throws_Should_Retry() if (hasElectionLockThrown) { // Signal to the test that a subsequent call has been made. - electionLockSubsequentCallEvent.Set(); + subsequentCallTcs.TrySetResult(true); // Delay returning for a long time, allowing the test to stop the background service, in turn cancelling the cancellation token. await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); @@ -54,8 +54,9 @@ public async Task Elector_Throws_Should_Retry() await leaderElectionBackgroundService.StartAsync(CancellationToken.None); // Starting the background service should result in the lock attempt throwing, and then a subsequent attempt being made. - // Wait for the subsequent event to be signalled, if we time out the test fails. The retry delay requires us to wait at least 3 seconds. - electionLockSubsequentCallEvent.WaitOne(TimeSpan.FromMilliseconds(3100)).Should().BeTrue(); + // Wait for the retry to be signalled; use a generous timeout so CI scheduling jitter doesn't cause false failures. + var completed = await Task.WhenAny(subsequentCallTcs.Task, Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + completed.Should().Be(subsequentCallTcs.Task, "the leader elector should retry after throwing"); await leaderElectionBackgroundService.StopAsync(CancellationToken.None); } 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..2376c1e87 --- /dev/null +++ b/test/KubeOps.Operator.Test/Reconciliation/Reconciler.MultiController.Test.cs @@ -0,0 +1,493 @@ +// 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); + } + + // ── mocked ShouldHandle returning true = catch-all ─────────────────────── + + [Fact] + public async Task Dispatch_ShouldHandleReturningTrue_MatchesEntityWithNoLabels() + { + var entity = CreateEntity(); + var controller = CreateController(shouldHandle: _ => true); + 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_ShouldHandleReturningTrue_MatchesEntityWithLabels() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = CreateController(shouldHandle: _ => true); + 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); + } + + // ── default interface method exercise (no mock) ────────────────────────── + + [Fact] + public async Task Dispatch_ConcreteControllerWithoutOverride_UsesDefaultInterfaceMethod_AndDispatches() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = new DefaultShouldHandleController(); + var reconciler = CreateReconciler([controller]); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + var result = await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeTrue(); + controller.ReconcileCallCount.Should().Be(1); + } + + // ── ShouldHandle label-based claim ──────────────────────────────────────── + + [Fact] + public async Task Dispatch_ShouldHandleMatches_ControllerIsCalled() + { + var entity = CreateEntity(labels: new() { ["env"] = "prod" }); + var controller = CreateController(shouldHandle: e => GetLabel(e, "env") == "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_ShouldHandleDoesNotMatch_ControllerIsNotCalled() + { + var entity = CreateEntity(labels: new() { ["env"] = "staging" }); + var controller = CreateController(shouldHandle: e => GetLabel(e, "env") == "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(shouldHandle: e => GetLabel(e, "env") == "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( + shouldHandle: e => GetLabel(e, "env") == "prod", + onReconcile: e => + { + callOrder.Add(1); + return ReconciliationResult.Success(e); + }); + var ctrl2 = CreateController( + shouldHandle: _ => true, + 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(shouldHandle: e => GetLabel(e, "env") == "prod"); + var stagingCtrl = CreateController(shouldHandle: e => GetLabel(e, "env") == "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(shouldHandle: _ => true, onReconcile: _ => failResult); + var ctrl2 = CreateController(shouldHandle: _ => true); + + 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(shouldHandle: e => GetLabel(e, "env") == "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(shouldHandle: e => GetLabel(e, "env") == "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( + shouldHandle: _ => true, + onReconcile: _ => ReconciliationResult.Success(mutated)); + + V1ConfigMap? receivedByCtrl2 = null; + var ctrl2 = CreateController( + shouldHandle: _ => true, + 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(shouldHandle: e => GetLabel(e, "env") == "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); + } + + // ── async ShouldHandle is awaited ───────────────────────────────────────── + + [Fact] + public async Task Dispatch_AsyncShouldHandle_IsAwaitedBeforeDispatch() + { + var entity = CreateEntity(); + var shouldHandleCalled = false; + var reconcileCalledAfter = false; + + var mock = new Mock>(); + mock.Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(async (V1ConfigMap _, CancellationToken __) => + { + await Task.Yield(); + shouldHandleCalled = true; + return true; + }); + mock.Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((V1ConfigMap e, CancellationToken _) => + { + reconcileCalledAfter = shouldHandleCalled; + return ReconciliationResult.Success(e); + }); + + var reconciler = CreateReconciler([mock.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + reconcileCalledAfter.Should().BeTrue(); + } + + // ── RequeueAfter aggregation ───────────────────────────────────────────── + + [Fact] + public async Task Dispatch_MultipleControllersWithRequeueAfter_KeepsEarliestNonNull() + { + var entity = CreateEntity(); + + var ctrl1 = CreateController( + shouldHandle: _ => true, + onReconcile: e => ReconciliationResult.Success(e, TimeSpan.FromMinutes(10))); + var ctrl2 = CreateController( + shouldHandle: _ => true, + onReconcile: e => ReconciliationResult.Success(e, TimeSpan.FromMinutes(2))); + var ctrl3 = CreateController( + shouldHandle: _ => true, + onReconcile: e => ReconciliationResult.Success(e, TimeSpan.FromMinutes(5))); + + var reconciler = CreateReconciler([ctrl1.Object, ctrl2.Object, ctrl3.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + var result = await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeTrue(); + result.RequeueAfter.Should().Be(TimeSpan.FromMinutes(2)); + } + + [Fact] + public async Task Dispatch_LaterControllerReturnsNullRequeueAfter_DoesNotEraseEarlierRequeue() + { + var entity = CreateEntity(); + + var ctrl1 = CreateController( + shouldHandle: _ => true, + onReconcile: e => ReconciliationResult.Success(e, TimeSpan.FromMinutes(3))); + var ctrl2 = CreateController( + shouldHandle: _ => true, + onReconcile: e => ReconciliationResult.Success(e)); + + 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().BeTrue(); + result.RequeueAfter.Should().Be(TimeSpan.FromMinutes(3)); + } + + // ── just-in-time ShouldHandle sees mutated entity ──────────────────────── + + [Fact] + public async Task Dispatch_ControllerMutatesEntity_SecondControllerShouldHandleSeesMutation() + { + var original = CreateEntity(labels: new() { ["env"] = "prod" }); + var mutated = CreateEntity(name: "mutated", labels: new() { ["env"] = "staging" }); + + var ctrl1 = CreateController( + shouldHandle: _ => true, + onReconcile: _ => ReconciliationResult.Success(mutated)); + + // ctrl2 would claim the original (env=prod) but not the mutated (env=staging) entity. + // With JIT evaluation against the current entity, ctrl2 must NOT be dispatched. + var ctrl2 = CreateController(shouldHandle: e => GetLabel(e, "env") == "prod"); + + var reconciler = CreateReconciler([ctrl1.Object, ctrl2.Object]); + var context = ReconciliationContext.CreateFromApiServerEvent(original, WatchEventType.Added); + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + ctrl1.Verify(c => c.ReconcileAsync(It.IsAny(), It.IsAny()), Times.Once); + ctrl2.Verify(c => c.ReconcileAsync(It.IsAny(), It.IsAny()), Times.Never); + ctrl2.Verify(c => c.ShouldHandle(mutated, It.IsAny()), Times.Once); + } + + // ── misconfiguration: zero registrations ───────────────────────────────── + + [Fact] + public async Task Dispatch_NoControllersRegistered_ReturnsFailure() + { + var entity = CreateEntity(); + var reconciler = CreateReconciler([]); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + var result = await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + result.IsSuccess.Should().BeFalse(); + result.ErrorMessage.Should().Contain("No IEntityController"); + } + + // ── cancellation ───────────────────────────────────────────────────────── + + [Fact] + public async Task Dispatch_CancelledBeforeShouldHandle_ThrowsOperationCanceled() + { + var entity = CreateEntity(); + var controller = CreateController(shouldHandle: _ => true); + var reconciler = CreateReconciler([controller.Object]); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); + + await Assert.ThrowsAnyAsync( + async () => await reconciler.Reconcile(context, cts.Token)); + + controller.Verify( + c => c.ReconcileAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + /// + /// Concrete controller with no override — + /// used to verify the default interface method (ValueTask.FromResult(true)) is invoked. + /// + private sealed class DefaultShouldHandleController : IEntityController + { + public int ReconcileCallCount { get; private set; } + + public Task> ReconcileAsync(V1ConfigMap entity, CancellationToken cancellationToken) + { + ReconcileCallCount++; + return Task.FromResult(ReconciliationResult.Success(entity)); + } + + public Task> DeletedAsync(V1ConfigMap entity, CancellationToken cancellationToken) => + Task.FromResult(ReconciliationResult.Success(entity)); + } + + 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( + Func shouldHandle, + Func>? onReconcile = null) + { + var mock = new Mock>(); + + mock.Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns((V1ConfigMap e, CancellationToken _) => ValueTask.FromResult(shouldHandle(e))); + + 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 string? GetLabel(V1ConfigMap entity, string key) => + entity.Labels() is { } labels && labels.TryGetValue(key, out var value) ? value : null; + + 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..7ade8fc8f 100644 --- a/test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs +++ b/test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs @@ -149,6 +149,10 @@ public async Task Reconcile_Should_Call_ReconcileAsync_For_Added_Event() var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Added); var mockController = new Mock>(); + mockController + .Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + mockController .Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ReconciliationResult.Success(entity)); @@ -169,6 +173,10 @@ public async Task Reconcile_Should_Call_ReconcileAsync_For_Modified_Event_With_N var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Modified); var mockController = new Mock>(); + mockController + .Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + mockController .Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ReconciliationResult.Success(entity)); @@ -211,6 +219,10 @@ public async Task Reconcile_Should_Call_DeletedAsync_For_Deleted_Event() var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Deleted); var mockController = new Mock>(); + mockController + .Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + mockController .Setup(c => c.DeletedAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ReconciliationResult.Success(entity)); @@ -365,6 +377,10 @@ public async Task Reconcile_When_Auto_Attach_Finalizers_Is_Enabled_Should_Attach var mockController = new Mock>(); var mockFinalizer = new Mock>(); + mockFinalizer + .Setup(f => f.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + _mockClient .Setup(c => c.UpdateAsync(It.Is( e => e == entity), @@ -377,6 +393,10 @@ public async Task Reconcile_When_Auto_Attach_Finalizers_Is_Enabled_Should_Attach It.Is(o => ReferenceEquals(o, KeyedService.AnyKey)))) .Returns(new List> { mockFinalizer.Object }); + mockController + .Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + mockController .Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ReconciliationResult.Success(entity)); @@ -391,6 +411,45 @@ public async Task Reconcile_When_Auto_Attach_Finalizers_Is_Enabled_Should_Attach Times.Once); } + [Fact] + public async Task Reconcile_When_Auto_Attach_Finalizer_ShouldHandle_Returns_False_Should_Not_Attach() + { + _settings.AutoAttachFinalizers = true; + + var entity = CreateTestEntity(); + var context = ReconciliationContext.CreateFromApiServerEvent(entity, WatchEventType.Modified); + var mockController = new Mock>(); + var mockFinalizer = new Mock>(); + + mockFinalizer + .Setup(f => f.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(false)); + + _mockServiceProvider + .Setup(p => p.GetRequiredKeyedService( + It.Is(t => t == typeof(IEnumerable>)), + It.Is(o => ReferenceEquals(o, KeyedService.AnyKey)))) + .Returns(new List> { mockFinalizer.Object }); + + mockController + .Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + + mockController + .Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ReconciliationResult.Success(entity)); + + var reconciler = CreateReconcilerForController(mockController.Object); + + await reconciler.Reconcile(context, TestContext.Current.CancellationToken); + + mockFinalizer.Verify(f => f.ShouldHandle(It.IsAny(), It.IsAny()), Times.Once); + _mockClient.Verify( + c => c.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + entity.Finalizers().Should().BeNullOrEmpty(); + } + [Fact] public async Task Reconcile_When_Auto_Attach_Finalizers_Is_Enabled_But_No_Finalizer_Is_Defined_Should_Not_Update() { @@ -406,6 +465,10 @@ public async Task Reconcile_When_Auto_Attach_Finalizers_Is_Enabled_But_No_Finali It.Is(o => ReferenceEquals(o, KeyedService.AnyKey)))) .Returns(() => new List>()); + mockController + .Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + mockController .Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(() => ReconciliationResult.Success(entity)); @@ -471,8 +534,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, @@ -522,6 +585,10 @@ private static IEntityController CreateMockController( var mockController = new Mock>(); var entity = CreateTestEntity(); + mockController + .Setup(c => c.ShouldHandle(It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult(true)); + mockController .Setup(c => c.ReconcileAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(reconcileResult ?? ReconciliationResult.Success(entity));