Skip to content

Commit 181daa8

Browse files
committed
feat: add onUpdateFilterCombinedWithOr option to ControllerConfiguration
Signed-off-by: xstefank <xstefank122@gmail.com>
1 parent dc1050c commit 181daa8

10 files changed

Lines changed: 262 additions & 15 deletions

File tree

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ private <P extends HasMetadata> ResolvedControllerConfiguration<P> controllerCon
321321
var triggerReconcilerOnAllEvents =
322322
annotation != null && annotation.triggerReconcilerOnAllEvents();
323323

324+
var onUpdateFilterCombinedWithOr =
325+
annotation != null && annotation.onUpdateFilterCombinedWithOr();
326+
324327
InformerConfiguration<P> informerConfig =
325328
InformerConfiguration.builder(resourceClass)
326329
.initFromAnnotation(annotation != null ? annotation.informer() : null, context)
@@ -341,7 +344,8 @@ private <P extends HasMetadata> ResolvedControllerConfiguration<P> controllerCon
341344
dependentFieldManager,
342345
this,
343346
informerConfig,
344-
triggerReconcilerOnAllEvents);
347+
triggerReconcilerOnAllEvents,
348+
onUpdateFilterCombinedWithOr);
345349
}
346350

347351
/**

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,8 @@ default boolean triggerReconcilerOnAllEvent() {
121121
default boolean triggerReconcilerOnAllEvents() {
122122
return false;
123123
}
124+
125+
default boolean isOnUpdateFilterCombinedWithOr() {
126+
return false;
127+
}
124128
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public class ControllerConfigurationOverrider<R extends HasMetadata> {
4646
private Map<DependentResourceSpec, Object> configurations;
4747
private final InformerConfiguration<R>.Builder config;
4848
private boolean triggerReconcilerOnAllEvents;
49+
private boolean onUpdateFilterCombinedWithOr;
4950

5051
private ControllerConfigurationOverrider(ControllerConfiguration<R> original) {
5152
this.finalizer = original.getFinalizerName();
@@ -59,6 +60,7 @@ private ControllerConfigurationOverrider(ControllerConfiguration<R> original) {
5960
this.name = original.getName();
6061
this.fieldManager = original.fieldManager();
6162
this.triggerReconcilerOnAllEvents = original.triggerReconcilerOnAllEvents();
63+
this.onUpdateFilterCombinedWithOr = original.isOnUpdateFilterCombinedWithOr();
6264
}
6365

6466
public ControllerConfigurationOverrider<R> withFinalizer(String finalizer) {
@@ -186,6 +188,12 @@ public ControllerConfigurationOverrider<R> withTriggerReconcilerOnAllEvents(
186188
return this;
187189
}
188190

191+
public ControllerConfigurationOverrider<R> withOnUpdateFilterCombinedWithOr(
192+
boolean onUpdateFilterCombinedWithOr) {
193+
this.onUpdateFilterCombinedWithOr = onUpdateFilterCombinedWithOr;
194+
return this;
195+
}
196+
189197
/**
190198
* Sets a max page size limit when starting the informer. This will result in pagination while
191199
* populating the cache. This means that longer lists will take multiple requests to fetch. See
@@ -231,6 +239,7 @@ public ControllerConfiguration<R> build() {
231239
original.getConfigurationService(),
232240
config.buildForController(),
233241
triggerReconcilerOnAllEvents,
242+
onUpdateFilterCombinedWithOr,
234243
original.getWorkflowSpec().orElse(null));
235244
}
236245

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public class ResolvedControllerConfiguration<P extends HasMetadata>
4545
private final ConfigurationService configurationService;
4646
private final String fieldManager;
4747
private final boolean triggerReconcilerOnAllEvents;
48+
private final boolean onUpdateFilterCombinedWithOr;
4849
private WorkflowSpec workflowSpec;
4950

5051
public ResolvedControllerConfiguration(ControllerConfiguration<P> other) {
@@ -61,6 +62,7 @@ public ResolvedControllerConfiguration(ControllerConfiguration<P> other) {
6162
other.getConfigurationService(),
6263
other.getInformerConfig(),
6364
other.triggerReconcilerOnAllEvents(),
65+
other.isOnUpdateFilterCombinedWithOr(),
6466
other.getWorkflowSpec().orElse(null));
6567
}
6668

@@ -77,6 +79,7 @@ public ResolvedControllerConfiguration(
7779
ConfigurationService configurationService,
7880
InformerConfiguration<P> informerConfig,
7981
boolean triggerReconcilerOnAllEvents,
82+
boolean onUpdateFilterCombinedWithOr,
8083
WorkflowSpec workflowSpec) {
8184
this(
8285
name,
@@ -90,7 +93,8 @@ public ResolvedControllerConfiguration(
9093
fieldManager,
9194
configurationService,
9295
informerConfig,
93-
triggerReconcilerOnAllEvents);
96+
triggerReconcilerOnAllEvents,
97+
onUpdateFilterCombinedWithOr);
9498
setWorkflowSpec(workflowSpec);
9599
}
96100

@@ -106,7 +110,8 @@ protected ResolvedControllerConfiguration(
106110
String fieldManager,
107111
ConfigurationService configurationService,
108112
InformerConfiguration<P> informerConfig,
109-
boolean triggerReconcilerOnAllEvents) {
113+
boolean triggerReconcilerOnAllEvents,
114+
boolean onUpdateFilterCombinedWithOr) {
110115
this.informerConfig = informerConfig;
111116
this.configurationService = configurationService;
112117
this.name = ControllerConfiguration.ensureValidName(name, associatedReconcilerClassName);
@@ -120,6 +125,7 @@ protected ResolvedControllerConfiguration(
120125
ControllerConfiguration.ensureValidFinalizerName(finalizer, getResourceTypeName());
121126
this.fieldManager = fieldManager;
122127
this.triggerReconcilerOnAllEvents = triggerReconcilerOnAllEvents;
128+
this.onUpdateFilterCombinedWithOr = onUpdateFilterCombinedWithOr;
123129
}
124130

125131
protected ResolvedControllerConfiguration(
@@ -139,6 +145,7 @@ protected ResolvedControllerConfiguration(
139145
null,
140146
configurationService,
141147
InformerConfiguration.builder(resourceClass).buildForController(),
148+
false,
142149
false);
143150
}
144151

@@ -234,4 +241,9 @@ public String fieldManager() {
234241
public boolean triggerReconcilerOnAllEvents() {
235242
return triggerReconcilerOnAllEvents;
236243
}
244+
245+
@Override
246+
public boolean isOnUpdateFilterCombinedWithOr() {
247+
return onUpdateFilterCombinedWithOr;
248+
}
237249
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,16 @@ MaxReconciliationInterval maxReconciliationInterval() default
105105
* documentation for further details.
106106
*/
107107
boolean triggerReconcilerOnAllEvents() default false;
108+
109+
/**
110+
* When set to {@code true}, the {@link
111+
* io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter} configured via
112+
* {@link Informer#onUpdateFilter()} is combined with JOSDK's internal update filters using OR
113+
* instead of the default AND logic. This allows the user filter to expand the set of events that
114+
* trigger reconciliation — for example, to also reconcile on specific status field updates even
115+
* when the resource generation has not changed.
116+
*
117+
* @return whether the user-provided update filter is combined with internal filters using OR
118+
*/
119+
boolean onUpdateFilterCombinedWithOr() default false;
108120
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,13 @@ public ControllerEventSource(Controller<T> controller) {
6262
Optional.ofNullable(informerConfig.getOnAddFilter()).ifPresent(this::setOnAddFilter);
6363
Optional.ofNullable(informerConfig.getOnUpdateFilter())
6464
.ifPresentOrElse(
65-
filter -> setOnUpdateFilter(filter.and(internalOnUpdateFilter)),
65+
filter -> {
66+
if (config.isOnUpdateFilterCombinedWithOr()) {
67+
setOnUpdateFilter(filter.or(internalOnUpdateFilter));
68+
} else {
69+
setOnUpdateFilter(filter.and(internalOnUpdateFilter));
70+
}
71+
},
6672
() -> setOnUpdateFilter(internalOnUpdateFilter));
6773
Optional.ofNullable(informerConfig.getGenericFilter()).ifPresent(this::setGenericFilter);
6874
setControllerConfiguration(config);

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,45 @@ void callsBroadcastsOnResourceEvents() {
140140
eq(ResourceAction.UPDATED), eq(customResource1), eq(customResource1));
141141
}
142142

143+
@Test
144+
void orCombinedFilterTriggersEventWhenInternalFilterWouldReject() {
145+
TestCustomResource cr = TestUtils.testCustomResource();
146+
cr.getMetadata().setFinalizers(List.of(FINALIZER));
147+
cr.getMetadata().setGeneration(1L);
148+
149+
OnUpdateFilter<TestCustomResource> userFilter = (newRes, oldRes) -> true;
150+
source = new ControllerEventSource<>(new TestController(null, userFilter, null, true));
151+
setUpSource(source, true, controllerConfig);
152+
153+
source.handleEvent(ResourceAction.UPDATED, cr, cr, null);
154+
155+
verify(eventHandler, times(1)).handleEvent(any());
156+
}
157+
158+
@Test
159+
void orCombinedFilterDoesNotTriggerWhenUserFilterAlsoRejects() {
160+
TestCustomResource cr = TestUtils.testCustomResource();
161+
cr.getMetadata().setFinalizers(List.of(FINALIZER));
162+
cr.getMetadata().setGeneration(1L);
163+
164+
OnUpdateFilter<TestCustomResource> userFilter = (newRes, oldRes) -> false;
165+
source = new ControllerEventSource<>(new TestController(null, userFilter, null, true));
166+
setUpSource(source, true, controllerConfig);
167+
168+
source.handleEvent(ResourceAction.UPDATED, cr, cr, null);
169+
170+
verify(eventHandler, never()).handleEvent(any());
171+
}
172+
143173
@Test
144174
void filtersOutEventsOnAddAndUpdate() {
145175
TestCustomResource cr = TestUtils.testCustomResource();
146176

147177
OnAddFilter<TestCustomResource> onAddFilter = (res) -> false;
148178
OnUpdateFilter<TestCustomResource> onUpdatePredicate = (res, res2) -> false;
149-
source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null));
179+
source =
180+
new ControllerEventSource<>(
181+
new TestController(onAddFilter, onUpdatePredicate, null, false));
150182
setUpSource(source, true, controllerConfig);
151183

152184
source.handleEvent(ResourceAction.ADDED, cr, null, null);
@@ -159,7 +191,7 @@ void filtersOutEventsOnAddAndUpdate() {
159191
void genericFilterFiltersOutAddUpdateAndDeleteEvents() {
160192
TestCustomResource cr = TestUtils.testCustomResource();
161193

162-
source = new ControllerEventSource<>(new TestController(null, null, res -> false));
194+
source = new ControllerEventSource<>(new TestController(null, null, res -> false, false));
163195
setUpSource(source, true, controllerConfig);
164196

165197
source.handleEvent(ResourceAction.ADDED, cr, null, null);
@@ -174,7 +206,7 @@ void ownUpdateEchoIsFilteredOutByEventFilter() throws InterruptedException {
174206
// End-to-end smoke for the event-filter wiring on the controller path: an event for our
175207
// own write must not propagate. Detail-level filter scenarios are covered in
176208
// EventingDetailTest / EventFilterSupportTest.
177-
source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
209+
source = spy(new ControllerEventSource<>(new TestController(null, null, null, false)));
178210
setUpSource(source, true, controllerConfig);
179211
doReturn(Optional.empty()).when(source).get(any());
180212

@@ -189,7 +221,7 @@ void ownUpdateEchoIsFilteredOutByEventFilter() throws InterruptedException {
189221
@Test
190222
void foreignUpdateDuringFilteringPropagatesAsUpdate() {
191223
// An external event during the filter window must surface (not be filtered as own).
192-
source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
224+
source = spy(new ControllerEventSource<>(new TestController(null, null, null, false)));
193225
setUpSource(source, true, controllerConfig);
194226

195227
var latch = sendForEventFilteringUpdate(2);
@@ -203,7 +235,7 @@ void foreignUpdateDuringFilteringPropagatesAsUpdate() {
203235
void deleteEventDuringFilteringPropagatesAsDelete() {
204236
// A DELETE arriving during the filter window must surface — the resource has gone,
205237
// so the filter must not silence it just because our own write is still tracking RVs.
206-
source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
238+
source = spy(new ControllerEventSource<>(new TestController(null, null, null, false)));
207239
setUpSource(source, true, controllerConfig);
208240

209241
var latch = sendForEventFilteringUpdate(2);
@@ -223,7 +255,7 @@ void deleteEventDuringFilteringPropagatesAsDelete() {
223255
void multipleForeignEventsDuringFilteringMergeIntoSingleEvent() {
224256
// Several external events during one filter window collapse into a single
225257
// synthesized event spanning prev → latest seen.
226-
source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
258+
source = spy(new ControllerEventSource<>(new TestController(null, null, null, false)));
227259
setUpSource(source, true, controllerConfig);
228260

229261
var latch = sendForEventFilteringUpdate(2);
@@ -266,17 +298,19 @@ private static class TestController extends Controller<TestCustomResource> {
266298
public TestController(
267299
OnAddFilter<TestCustomResource> onAddFilter,
268300
OnUpdateFilter<TestCustomResource> onUpdateFilter,
269-
GenericFilter<TestCustomResource> genericFilter) {
301+
GenericFilter<TestCustomResource> genericFilter,
302+
boolean onUpdateFilterCombinedWithOr) {
270303
super(
271304
reconciler,
272-
new TestConfiguration(true, onAddFilter, onUpdateFilter, genericFilter),
305+
new TestConfiguration(
306+
true, onAddFilter, onUpdateFilter, genericFilter, onUpdateFilterCombinedWithOr),
273307
MockKubernetesClient.client(TestCustomResource.class));
274308
}
275309

276310
public TestController(boolean generationAware) {
277311
super(
278312
reconciler,
279-
new TestConfiguration(generationAware, null, null, null),
313+
new TestConfiguration(generationAware, null, null, null, false),
280314
MockKubernetesClient.client(TestCustomResource.class));
281315
}
282316

@@ -298,7 +332,8 @@ public TestConfiguration(
298332
boolean generationAware,
299333
OnAddFilter<TestCustomResource> onAddFilter,
300334
OnUpdateFilter<TestCustomResource> onUpdateFilter,
301-
GenericFilter<TestCustomResource> genericFilter) {
335+
GenericFilter<TestCustomResource> genericFilter,
336+
boolean onUpdateFilterCombinedWithOr) {
302337
super(
303338
"test",
304339
generationAware,
@@ -316,7 +351,8 @@ public TestConfiguration(
316351
.withGenericFilter(genericFilter)
317352
.withComparableResourceVersions(true)
318353
.buildForController(),
319-
false);
354+
false,
355+
onUpdateFilterCombinedWithOr);
320356
}
321357
}
322358
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.baseapi.filter;
17+
18+
import java.time.Duration;
19+
import java.util.Map;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.extension.RegisterExtension;
23+
24+
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
25+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
import static org.awaitility.Awaitility.await;
29+
30+
class OrCombinedFilterIT {
31+
32+
public static final String RESOURCE_NAME = "or-filter-test";
33+
public static final int POLL_DELAY = 150;
34+
35+
@RegisterExtension
36+
LocallyRunOperatorExtension operator =
37+
LocallyRunOperatorExtension.builder()
38+
.withReconciler(new OrCombinedFilterTestReconciler())
39+
.build();
40+
41+
@Test
42+
void orCombinedFilterTriggersReconciliationEvenWhenInternalFilterWouldReject() {
43+
operator.create(createResource());
44+
45+
await()
46+
.pollDelay(Duration.ofMillis(POLL_DELAY))
47+
.untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(1));
48+
49+
// Spec update bumps generation — internal generation-aware filter accepts → reconcile
50+
var res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME);
51+
res.getSpec().setValue("updated");
52+
operator.replace(res);
53+
54+
await()
55+
.pollDelay(Duration.ofMillis(POLL_DELAY))
56+
.untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(2));
57+
58+
// Annotation-only update does not bump generation — internal filter would reject,
59+
// but with OR combination the user filter accepts
60+
res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME);
61+
res.getMetadata()
62+
.setAnnotations(Map.of(OrCombinedFilterTestReconciler.TRIGGER_ANNOTATION, "true"));
63+
operator.replace(res);
64+
65+
await()
66+
.pollDelay(Duration.ofMillis(POLL_DELAY))
67+
.untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(3));
68+
69+
// Removing the annotation: user filter rejects, no generation change, should not reconcile
70+
res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME);
71+
res.getMetadata().getAnnotations().remove(OrCombinedFilterTestReconciler.TRIGGER_ANNOTATION);
72+
operator.replace(res);
73+
74+
await()
75+
.pollDelay(Duration.ofMillis(POLL_DELAY))
76+
.untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(3));
77+
}
78+
79+
private OrCombinedFilterTestReconciler reconciler() {
80+
return operator.getReconcilerOfType(OrCombinedFilterTestReconciler.class);
81+
}
82+
83+
FilterTestCustomResource createResource() {
84+
var resource = new FilterTestCustomResource();
85+
resource.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build());
86+
resource.setSpec(new FilterTestResourceSpec());
87+
resource.getSpec().setValue("initial");
88+
return resource;
89+
}
90+
}

0 commit comments

Comments
 (0)