From 791e77e28eb382996963b66ce4e82448fef965ca Mon Sep 17 00:00:00 2001 From: xstefank Date: Mon, 22 Jun 2026 16:21:20 +0200 Subject: [PATCH] fix: add onUpdateFilterOr option to the Informer Signed-off-by: xstefank --- .../api/config/informer/Informer.java | 16 +++- .../informer/InformerConfiguration.java | 16 ++++ .../InformerEventSourceConfiguration.java | 6 ++ .../controller/ControllerEventSource.java | 12 ++- .../controller/ControllerEventSourceTest.java | 54 ++++++++++-- .../operator/baseapi/filter/OrFilterIT.java | 87 +++++++++++++++++++ .../filter/OrFilterTestReconciler.java | 43 +++++++++ .../baseapi/filter/OrUpdateFilter.java | 29 +++++++ 8 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterTestReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrUpdateFilter.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java index 7f0d266684..a117379bbe 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java @@ -80,13 +80,27 @@ Class onAddFilter() default OnAddFilter.class; /** - * Optional {@link OnUpdateFilter} to filter update events sent to the associated informer + * Optional {@link OnUpdateFilter} to filter update events sent to the associated informer. + * Combined with JOSDK's internal filters using AND logic — the event is only accepted when both + * this filter and JOSDK's internal filters accept it. * * @return the {@link OnUpdateFilter} filter implementation to use, defaulting to the interface * itself if no value is set */ Class onUpdateFilter() default OnUpdateFilter.class; + /** + * Optional {@link OnUpdateFilter} combined with JOSDK's internal filters using OR logic — the + * event is accepted when either this filter or JOSDK's internal filters accept it. Use this to + * expand the set of events that trigger reconciliation beyond what JOSDK's internal filters (e.g. + * generation-aware filtering) would normally allow, for instance to also reconcile on specific + * status field updates. + * + * @return the {@link OnUpdateFilter} filter implementation to use, defaulting to the interface + * itself if no value is set + */ + Class onUpdateFilterOr() default OnUpdateFilter.class; + /** * Optional {@link OnDeleteFilter} to filter delete events sent to the associated informer * diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java index 20d7df7136..0b7410db66 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java @@ -49,6 +49,7 @@ public class InformerConfiguration { private String labelSelector; private OnAddFilter onAddFilter; private OnUpdateFilter onUpdateFilter; + private OnUpdateFilter onUpdateFilterOr; private OnDeleteFilter onDeleteFilter; private GenericFilter genericFilter; private ItemStore itemStore; @@ -64,6 +65,7 @@ protected InformerConfiguration( String labelSelector, OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, + OnUpdateFilter onUpdateFilterOr, OnDeleteFilter onDeleteFilter, GenericFilter genericFilter, ItemStore itemStore, @@ -79,6 +81,7 @@ protected InformerConfiguration( this.labelSelector = labelSelector; this.onAddFilter = onAddFilter; this.onUpdateFilter = onUpdateFilter; + this.onUpdateFilterOr = onUpdateFilterOr; this.onDeleteFilter = onDeleteFilter; this.genericFilter = genericFilter; this.itemStore = itemStore; @@ -115,6 +118,7 @@ public static InformerConfiguration.Builder builder( original.labelSelector, original.onAddFilter, original.onUpdateFilter, + original.onUpdateFilterOr, original.onDeleteFilter, original.genericFilter, original.itemStore, @@ -259,6 +263,10 @@ public OnUpdateFilter getOnUpdateFilter() { return onUpdateFilter; } + public OnUpdateFilter getOnUpdateFilterOr() { + return onUpdateFilterOr; + } + public OnDeleteFilter getOnDeleteFilter() { return onDeleteFilter; } @@ -359,6 +367,9 @@ public InformerConfiguration.Builder initFromAnnotation( withOnUpdateFilter( Utils.instantiate(informerConfig.onUpdateFilter(), OnUpdateFilter.class, context)); + withOnUpdateFilterOr( + Utils.instantiate(informerConfig.onUpdateFilterOr(), OnUpdateFilter.class, context)); + withOnDeleteFilter( Utils.instantiate(informerConfig.onDeleteFilter(), OnDeleteFilter.class, context)); @@ -456,6 +467,11 @@ public Builder withOnUpdateFilter(OnUpdateFilter onUpdateFilter) { return this; } + public Builder withOnUpdateFilterOr(OnUpdateFilter onUpdateFilterOr) { + InformerConfiguration.this.onUpdateFilterOr = onUpdateFilterOr; + return this; + } + public Builder withOnDeleteFilter(OnDeleteFilter onDeleteFilter) { InformerConfiguration.this.onDeleteFilter = onDeleteFilter; return this; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java index 1a1d8956fc..b2ff56a511 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java @@ -261,6 +261,11 @@ public Builder withOnUpdateFilter(OnUpdateFilter onUpdateFilter) { return this; } + public Builder withOnUpdateFilterOr(OnUpdateFilter onUpdateFilterOr) { + config.withOnUpdateFilterOr(onUpdateFilterOr); + return this; + } + public Builder withOnDeleteFilter(OnDeleteFilter onDeleteFilter) { config.withOnDeleteFilter(onDeleteFilter); return this; @@ -311,6 +316,7 @@ public void updateFrom(InformerConfiguration informerConfig) { .withItemStore(informerConfig.getItemStore()) .withOnAddFilter(informerConfig.getOnAddFilter()) .withOnUpdateFilter(informerConfig.getOnUpdateFilter()) + .withOnUpdateFilterOr(informerConfig.getOnUpdateFilterOr()) .withOnDeleteFilter(informerConfig.getOnDeleteFilter()) .withGenericFilter(informerConfig.getGenericFilter()) .withInformerListLimit(informerConfig.getInformerListLimit()) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index dfa94577f7..d94d9914e4 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -60,10 +60,16 @@ public ControllerEventSource(Controller controller) { // by default the on add should be processed in all cases regarding internal filters final var informerConfig = config.getInformerConfig(); Optional.ofNullable(informerConfig.getOnAddFilter()).ifPresent(this::setOnAddFilter); - Optional.ofNullable(informerConfig.getOnUpdateFilter()) + + var effectiveUpdateFilter = + Optional.ofNullable(informerConfig.getOnUpdateFilter()) + .map(filter -> filter.and(internalOnUpdateFilter)) + .orElse(internalOnUpdateFilter); + Optional.ofNullable(informerConfig.getOnUpdateFilterOr()) .ifPresentOrElse( - filter -> setOnUpdateFilter(filter.and(internalOnUpdateFilter)), - () -> setOnUpdateFilter(internalOnUpdateFilter)); + orFilter -> setOnUpdateFilter(effectiveUpdateFilter.or(orFilter)), + () -> setOnUpdateFilter(effectiveUpdateFilter)); + Optional.ofNullable(informerConfig.getGenericFilter()).ifPresent(this::setGenericFilter); setControllerConfiguration(config); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index f8cb54f68e..33879e4aaa 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -140,13 +140,48 @@ void callsBroadcastsOnResourceEvents() { eq(ResourceAction.UPDATED), eq(customResource1), eq(customResource1)); } + @Test + void orFilterTriggersEventWhenInternalFilterWouldReject() { + TestCustomResource cr = TestUtils.testCustomResource(); + cr.getMetadata().setFinalizers(List.of(FINALIZER)); + cr.getMetadata().setGeneration(1L); + + // Internal generation-aware filter would reject same-generation update, + // but the OR filter accepts it unconditionally. + OnUpdateFilter orFilter = (newRes, oldRes) -> true; + source = new ControllerEventSource<>(new TestController(null, null, orFilter, null)); + setUpSource(source, true, controllerConfig); + + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void orFilterDoesNotOverrideAndFilter() { + TestCustomResource cr = TestUtils.testCustomResource(); + cr.getMetadata().setFinalizers(List.of(FINALIZER)); + cr.getMetadata().setGeneration(1L); + + // AND filter rejects, OR filter also rejects → event must be dropped. + OnUpdateFilter andFilter = (newRes, oldRes) -> false; + OnUpdateFilter orFilter = (newRes, oldRes) -> false; + source = new ControllerEventSource<>(new TestController(null, andFilter, orFilter, null)); + setUpSource(source, true, controllerConfig); + + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + + verify(eventHandler, never()).handleEvent(any()); + } + @Test void filtersOutEventsOnAddAndUpdate() { TestCustomResource cr = TestUtils.testCustomResource(); OnAddFilter onAddFilter = (res) -> false; OnUpdateFilter onUpdatePredicate = (res, res2) -> false; - source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null)); + source = + new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null, null)); setUpSource(source, true, controllerConfig); source.handleEvent(ResourceAction.ADDED, cr, null, null); @@ -159,7 +194,7 @@ void filtersOutEventsOnAddAndUpdate() { void genericFilterFiltersOutAddUpdateAndDeleteEvents() { TestCustomResource cr = TestUtils.testCustomResource(); - source = new ControllerEventSource<>(new TestController(null, null, res -> false)); + source = new ControllerEventSource<>(new TestController(null, null, null, res -> false)); setUpSource(source, true, controllerConfig); source.handleEvent(ResourceAction.ADDED, cr, null, null); @@ -174,7 +209,7 @@ void ownUpdateEchoIsFilteredOutByEventFilter() throws InterruptedException { // End-to-end smoke for the event-filter wiring on the controller path: an event for our // own write must not propagate. Detail-level filter scenarios are covered in // EventingDetailTest / EventFilterSupportTest. - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, null))); setUpSource(source, true, controllerConfig); doReturn(Optional.empty()).when(source).get(any()); @@ -189,7 +224,7 @@ void ownUpdateEchoIsFilteredOutByEventFilter() throws InterruptedException { @Test void foreignUpdateDuringFilteringPropagatesAsUpdate() { // An external event during the filter window must surface (not be filtered as own). - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, null))); setUpSource(source, true, controllerConfig); var latch = sendForEventFilteringUpdate(2); @@ -203,7 +238,7 @@ void foreignUpdateDuringFilteringPropagatesAsUpdate() { void deleteEventDuringFilteringPropagatesAsDelete() { // A DELETE arriving during the filter window must surface — the resource has gone, // so the filter must not silence it just because our own write is still tracking RVs. - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, null))); setUpSource(source, true, controllerConfig); var latch = sendForEventFilteringUpdate(2); @@ -223,7 +258,7 @@ void deleteEventDuringFilteringPropagatesAsDelete() { void multipleForeignEventsDuringFilteringMergeIntoSingleEvent() { // Several external events during one filter window collapse into a single // synthesized event spanning prev → latest seen. - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, null))); setUpSource(source, true, controllerConfig); var latch = sendForEventFilteringUpdate(2); @@ -266,17 +301,18 @@ private static class TestController extends Controller { public TestController( OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, + OnUpdateFilter onUpdateFilterOr, GenericFilter genericFilter) { super( reconciler, - new TestConfiguration(true, onAddFilter, onUpdateFilter, genericFilter), + new TestConfiguration(true, onAddFilter, onUpdateFilter, onUpdateFilterOr, genericFilter), MockKubernetesClient.client(TestCustomResource.class)); } public TestController(boolean generationAware) { super( reconciler, - new TestConfiguration(generationAware, null, null, null), + new TestConfiguration(generationAware, null, null, null, null), MockKubernetesClient.client(TestCustomResource.class)); } @@ -298,6 +334,7 @@ public TestConfiguration( boolean generationAware, OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, + OnUpdateFilter onUpdateFilterOr, GenericFilter genericFilter) { super( "test", @@ -313,6 +350,7 @@ public TestConfiguration( InformerConfiguration.builder(TestCustomResource.class) .withOnAddFilter(onAddFilter) .withOnUpdateFilter(onUpdateFilter) + .withOnUpdateFilterOr(onUpdateFilterOr) .withGenericFilter(genericFilter) .withComparableResourceVersions(true) .buildForController(), diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterIT.java new file mode 100644 index 0000000000..a60e1cc16c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterIT.java @@ -0,0 +1,87 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.filter; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class OrFilterIT { + + public static final String RESOURCE_NAME = "or-filter-test"; + public static final int POLL_DELAY = 150; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new OrFilterTestReconciler()).build(); + + @Test + void orFilterTriggersReconciliationEvenWhenInternalFilterWouldReject() { + var res = operator.create(createResource()); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(1)); + + // Spec update bumps generation — internal generation-aware filter accepts -> reconcile + res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME); + res.getSpec().setValue("updated"); + operator.replace(res); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(2)); + + // Annotation-only update does not bump generation — internal filter would reject, + // but the OR filter accepts -> reconcile still happens + res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME); + res.getMetadata().setAnnotations(Map.of(OrFilterTestReconciler.TRIGGER_ANNOTATION, "true")); + operator.replace(res); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(3)); + + // Removing the annotation: OR filter rejects, no generation change -> no reconcile + res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME); + res.getMetadata().getAnnotations().remove(OrFilterTestReconciler.TRIGGER_ANNOTATION); + operator.replace(res); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(3)); + } + + private OrFilterTestReconciler reconciler() { + return operator.getReconcilerOfType(OrFilterTestReconciler.class); + } + + FilterTestCustomResource createResource() { + var resource = new FilterTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + resource.setSpec(new FilterTestResourceSpec()); + resource.getSpec().setValue("initial"); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterTestReconciler.java new file mode 100644 index 0000000000..931da3037a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrFilterTestReconciler.java @@ -0,0 +1,43 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.filter; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration(informer = @Informer(onUpdateFilterOr = OrUpdateFilter.class)) +public class OrFilterTestReconciler implements Reconciler { + + public static final String TRIGGER_ANNOTATION = "trigger-or-filter"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + FilterTestCustomResource resource, Context context) { + numberOfExecutions.incrementAndGet(); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrUpdateFilter.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrUpdateFilter.java new file mode 100644 index 0000000000..04cd47b8be --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/OrUpdateFilter.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.filter; + +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +public class OrUpdateFilter implements OnUpdateFilter { + + @Override + public boolean accept( + FilterTestCustomResource newResource, FilterTestCustomResource oldResource) { + var annotations = newResource.getMetadata().getAnnotations(); + return annotations != null + && "true".equals(annotations.get(OrFilterTestReconciler.TRIGGER_ANNOTATION)); + } +}