From 1caba0b6efdc148b7b5e442b86599f3a283a2508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 13 Sep 2025 17:09:41 +0200 Subject: [PATCH 01/12] feat: expectation pattern support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/expectation/Expectation.java | 32 ++++++++++ .../expectation/ExpectationManager.java | 59 +++++++++++++++++++ .../expectation/ExpectationResult.java | 15 +++++ .../expectation/ExpectationStatus.java | 7 +++ .../expectation/RegisteredExpectation.java | 14 +++++ 5 files changed, 127 insertions(+) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java new file mode 100644 index 0000000000..c9a026cd53 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.util.function.BiPredicate; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public interface Expectation

{ + + String UNNAMED = "unnamed"; + + boolean isFulfilled(P primary, Context

context); + + default String name() { + return UNNAMED; + } + + static

Expectation

createExpectation( + String name, BiPredicate> predicate) { + return new Expectation<>() { + @Override + public String name() { + return name; + } + + @Override + public boolean isFulfilled(P primary, Context

context) { + return predicate.test(primary, context); + } + }; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java new file mode 100644 index 0000000000..1e97573694 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -0,0 +1,59 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class ExpectationManager

{ + + private final ConcurrentHashMap> registeredExpectations = + new ConcurrentHashMap<>(); + + public void setExpectation(P primary, Expectation

expectation, Duration timeout) { + registeredExpectations.put( + ResourceID.fromResource(primary), + new RegisteredExpectation<>(LocalDateTime.now(), timeout, expectation)); + } + + /** + * Checks if provided expectation is fulfilled. Return the expectation result. If the result of + * expectation is fulfilled or timeout, the expectation is automatically removed; + */ + public Optional> checkOnExpectation(P primary, Context

context) { + var resourceID = ResourceID.fromResource(primary); + var regExp = registeredExpectations.get(ResourceID.fromResource(primary)); + if (regExp == null) { + return Optional.empty(); + } + if (regExp.expectation().isFulfilled(primary, context)) { + registeredExpectations.remove(resourceID); + return Optional.of( + new ExpectationResult<>(regExp.expectation(), ExpectationStatus.FULFILLED)); + } else if (regExp.isTimedOut()) { + registeredExpectations.remove(resourceID); + return Optional.of( + new ExpectationResult<>(regExp.expectation(), ExpectationStatus.TIMED_OUT)); + } else { + return Optional.of( + new ExpectationResult<>(regExp.expectation(), ExpectationStatus.NOT_FULFILLED)); + } + } + + public boolean isExpectationPresent(P primary) { + return registeredExpectations.containsKey(ResourceID.fromResource(primary)); + } + + public Optional> getExpectation(P primary) { + var regExp = registeredExpectations.get(ResourceID.fromResource(primary)); + return Optional.ofNullable(regExp).map(RegisteredExpectation::expectation); + } + + public void cleanup(P primary) { + registeredExpectations.remove(ResourceID.fromResource(primary)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java new file mode 100644 index 0000000000..4c6535bb95 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public record ExpectationResult

( + Expectation

expectation, ExpectationStatus status) { + + public boolean isFulfilled() { + return status == ExpectationStatus.FULFILLED; + } + + public boolean isTimedOut() { + return status == ExpectationStatus.TIMED_OUT; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java new file mode 100644 index 0000000000..55ee791b9d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +public enum ExpectationStatus { + FULFILLED, + NOT_FULFILLED, + TIMED_OUT +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java new file mode 100644 index 0000000000..fe24f6dd25 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.time.LocalDateTime; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +record RegisteredExpectation

( + LocalDateTime registeredAt, Duration timeout, Expectation

expectation) { + + public boolean isTimedOut() { + return LocalDateTime.now().isAfter(registeredAt.plus(timeout)); + } +} From 2a72ea1da5104320aa43106fb5f68f44ceca5744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 15 Sep 2025 21:19:30 +0200 Subject: [PATCH 02/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/expectation/ExpectationManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java index 1e97573694..f7b995a904 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -22,7 +22,7 @@ public void setExpectation(P primary, Expectation

expectation, Duration timeo /** * Checks if provided expectation is fulfilled. Return the expectation result. If the result of - * expectation is fulfilled or timeout, the expectation is automatically removed; + * expectation is fulfilled or timed out, the expectation is automatically removed; */ public Optional> checkOnExpectation(P primary, Context

context) { var resourceID = ResourceID.fromResource(primary); From b567e0307887ecd7ac7d97e7924d6ad296860652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 10:04:52 +0200 Subject: [PATCH 03/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationManager.java | 6 ++- .../expectation/ExpectationResult.java | 4 ++ .../PeriodicCleanerExpectationManager.java | 43 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java index f7b995a904..f9f39d64d3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -11,7 +11,7 @@ public class ExpectationManager

{ - private final ConcurrentHashMap> registeredExpectations = + protected final ConcurrentHashMap> registeredExpectations = new ConcurrentHashMap<>(); public void setExpectation(P primary, Expectation

expectation, Duration timeout) { @@ -53,6 +53,10 @@ public Optional> getExpectation(P primary) { return Optional.ofNullable(regExp).map(RegisteredExpectation::expectation); } + public Optional getExpectationName(P primary) { + return getExpectation(primary).map(Expectation::name); + } + public void cleanup(P primary) { registeredExpectations.remove(ResourceID.fromResource(primary)); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java index 4c6535bb95..408050421a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java @@ -12,4 +12,8 @@ public boolean isFulfilled() { public boolean isTimedOut() { return status == ExpectationStatus.TIMED_OUT; } + + public String name() { + return expectation.name(); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java new file mode 100644 index 0000000000..f33740d43c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.time.LocalDateTime; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; + +public class PeriodicCleanerExpectationManager

+ extends ExpectationManager

{ + + private final Duration cleanupDelayAfterExpiration; + private final IndexedResourceCache

primaryCache; + + // todo fixes schedule + public PeriodicCleanerExpectationManager(Duration period, Duration cleanupDelayAfterExpiration) { + this.cleanupDelayAfterExpiration = cleanupDelayAfterExpiration; + this.primaryCache = null; + } + + public PeriodicCleanerExpectationManager(Duration period, IndexedResourceCache

primaryCache) { + this.cleanupDelayAfterExpiration = null; + this.primaryCache = primaryCache; + } + + public void clean() { + registeredExpectations + .entrySet() + .removeIf( + e -> { + if (cleanupDelayAfterExpiration != null) { + return LocalDateTime.now() + .isAfter( + e.getValue() + .registeredAt() + .plus(e.getValue().timeout()) + .plus(cleanupDelayAfterExpiration)); + } else { + return primaryCache.get(e.getKey()).isEmpty(); + } + }); + } +} From 01dc7daffea1d6f36546c85e0133f8bdbb6462a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 11:37:52 +0200 Subject: [PATCH 04/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../PeriodicCleanerExpectationManager.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java index f33740d43c..5478141e22 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java @@ -2,6 +2,9 @@ import java.time.Duration; import java.time.LocalDateTime; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; @@ -9,18 +12,32 @@ public class PeriodicCleanerExpectationManager

extends ExpectationManager

{ + private final ScheduledExecutorService scheduler = + Executors.newScheduledThreadPool( + 1, + r -> { + Thread thread = Executors.defaultThreadFactory().newThread(r); + thread.setDaemon(true); + return thread; + }); + private final Duration cleanupDelayAfterExpiration; private final IndexedResourceCache

primaryCache; - // todo fixes schedule public PeriodicCleanerExpectationManager(Duration period, Duration cleanupDelayAfterExpiration) { - this.cleanupDelayAfterExpiration = cleanupDelayAfterExpiration; - this.primaryCache = null; + this(period, cleanupDelayAfterExpiration, null); } public PeriodicCleanerExpectationManager(Duration period, IndexedResourceCache

primaryCache) { - this.cleanupDelayAfterExpiration = null; + this(period, null, primaryCache); + } + + private PeriodicCleanerExpectationManager( + Duration period, Duration cleanupDelayAfterExpiration, IndexedResourceCache

primaryCache) { + this.cleanupDelayAfterExpiration = cleanupDelayAfterExpiration; this.primaryCache = primaryCache; + scheduler.scheduleWithFixedDelay( + this::clean, period.toMillis(), period.toMillis(), TimeUnit.MICROSECONDS); } public void clean() { @@ -40,4 +57,8 @@ public void clean() { } }); } + + void stop() { + scheduler.shutdownNow(); + } } From 43cab4f0dee901b1482371b92187a24c02c772d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 15:57:55 +0200 Subject: [PATCH 05/12] unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationManagerTest.java | 158 ++++++++++++++++++ .../expectation/ExpectationStatusTest.java | 35 ++++ .../expectation/ExpectationTest.java | 58 +++++++ ...PeriodicCleanerExpectationManagerTest.java | 149 +++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java new file mode 100644 index 0000000000..399ea2652f --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java @@ -0,0 +1,158 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ExpectationManagerTest { + + private ExpectationManager expectationManager; + private ConfigMap configMap; + private Context context; + + @BeforeEach + void setUp() { + expectationManager = new ExpectationManager<>(); + configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder().withName("test-configmap").withNamespace("test-namespace").build()); + context = mock(Context.class); + } + + @Test + void setExpectationShouldStoreExpectation() { + Expectation expectation = mock(Expectation.class); + Duration timeout = Duration.ofMinutes(5); + + expectationManager.setExpectation(configMap, expectation, timeout); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + assertThat(expectationManager.getExpectation(configMap)).contains(expectation); + } + + @Test + void checkOnExpectationShouldReturnEmptyWhenNoExpectation() { + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isEmpty(); + } + + @Test + void checkOnExpectationShouldReturnFulfilledWhenExpectationMet() { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(configMap, context)).thenReturn(true); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isPresent(); + assertThat(result.get().status()).isEqualTo(ExpectationStatus.FULFILLED); + assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } + + @Test + void checkOnExpectationShouldReturnNotFulfilledWhenExpectationNotMet() { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(configMap, context)).thenReturn(false); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isPresent(); + assertThat(result.get().status()).isEqualTo(ExpectationStatus.NOT_FULFILLED); + assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void checkOnExpectationShouldReturnTimedOutWhenExpectationExpired() throws InterruptedException { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(configMap, context)).thenReturn(false); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + Thread.sleep(10); + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isPresent(); + assertThat(result.get().status()).isEqualTo(ExpectationStatus.TIMED_OUT); + assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } + + @Test + void getExpectationNameShouldReturnExpectationName() { + String expectedName = "test-expectation"; + Expectation expectation = mock(Expectation.class); + when(expectation.name()).thenReturn(expectedName); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + Optional name = expectationManager.getExpectationName(configMap); + + assertThat(name).contains(expectedName); + } + + @Test + void getExpectationNameShouldReturnEmptyWhenNoExpectation() { + Optional name = expectationManager.getExpectationName(configMap); + + assertThat(name).isEmpty(); + } + + @Test + void cleanupShouldRemoveExpectation() { + Expectation expectation = mock(Expectation.class); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + expectationManager.cleanup(configMap); + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } + + @Test + void shouldHandleMultipleExpectationsForDifferentResources() { + ConfigMap configMap2 = new ConfigMap(); + configMap2.setMetadata( + new ObjectMetaBuilder() + .withName("test-configmap-2") + .withNamespace("test-namespace") + .build()); + + Expectation expectation1 = mock(Expectation.class); + Expectation expectation2 = mock(Expectation.class); + + expectationManager.setExpectation(configMap, expectation1, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap2, expectation2, Duration.ofMinutes(5)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + assertThat(expectationManager.isExpectationPresent(configMap2)).isTrue(); + assertThat(expectationManager.getExpectation(configMap)).contains(expectation1); + assertThat(expectationManager.getExpectation(configMap2)).contains(expectation2); + } + + @Test + void setExpectationShouldReplaceExistingExpectation() { + Expectation expectation1 = mock(Expectation.class); + Expectation expectation2 = mock(Expectation.class); + + expectationManager.setExpectation(configMap, expectation1, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap, expectation2, Duration.ofMinutes(5)); + + assertThat(expectationManager.getExpectation(configMap)).contains(expectation2); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java new file mode 100644 index 0000000000..feba8cb651 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java @@ -0,0 +1,35 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExpectationStatusTest { + + @Test + void shouldHaveThreeStatuses() { + ExpectationStatus[] values = ExpectationStatus.values(); + + assertThat(values).hasSize(3); + assertThat(values) + .containsExactlyInAnyOrder( + ExpectationStatus.FULFILLED, + ExpectationStatus.NOT_FULFILLED, + ExpectationStatus.TIMED_OUT); + } + + @Test + void shouldHaveCorrectNames() { + assertThat(ExpectationStatus.FULFILLED.name()).isEqualTo("FULFILLED"); + assertThat(ExpectationStatus.NOT_FULFILLED.name()).isEqualTo("NOT_FULFILLED"); + assertThat(ExpectationStatus.TIMED_OUT.name()).isEqualTo("TIMED_OUT"); + } + + @Test + void shouldSupportValueOf() { + assertThat(ExpectationStatus.valueOf("FULFILLED")).isEqualTo(ExpectationStatus.FULFILLED); + assertThat(ExpectationStatus.valueOf("NOT_FULFILLED")) + .isEqualTo(ExpectationStatus.NOT_FULFILLED); + assertThat(ExpectationStatus.valueOf("TIMED_OUT")).isEqualTo(ExpectationStatus.TIMED_OUT); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java new file mode 100644 index 0000000000..7e94994bc3 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ExpectationTest { + + @Test + void createExpectationWithCustomName() { + String customName = "test-expectation"; + Expectation expectation = + Expectation.createExpectation(customName, (primary, context) -> true); + + assertThat(expectation.name()).isEqualTo(customName); + } + + @Test + void createExpectationWithPredicate() { + ConfigMap configMap = new ConfigMap(); + Context context = mock(Context.class); + + Expectation trueExpectation = + Expectation.createExpectation("always-true", (primary, ctx) -> true); + Expectation falseExpectation = + Expectation.createExpectation("always-false", (primary, ctx) -> false); + + assertThat(trueExpectation.isFulfilled(configMap, context)).isTrue(); + assertThat(falseExpectation.isFulfilled(configMap, context)).isFalse(); + } + + @Test + void expectationShouldWorkWithGenericTypes() { + ConfigMap configMap = new ConfigMap(); + Context context = mock(Context.class); + + Expectation expectation = + new Expectation<>() { + @Override + public String name() { + return "custom-expectation"; + } + + @Override + public boolean isFulfilled(ConfigMap primary, Context context) { + return primary != null; + } + }; + + assertThat(expectation.name()).isEqualTo("custom-expectation"); + assertThat(expectation.isFulfilled(configMap, context)).isTrue(); + assertThat(expectation.isFulfilled(null, context)).isFalse(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java new file mode 100644 index 0000000000..0bba070955 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java @@ -0,0 +1,149 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.when; + +class PeriodicCleanerExpectationManagerTest { + + @Mock private IndexedResourceCache primaryCache; + + private PeriodicCleanerExpectationManager expectationManager; + private ConfigMap configMap; + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder().withName("test-configmap").withNamespace("test-namespace").build()); + } + + @AfterEach + void tearDown() throws Exception { + if (expectationManager != null) { + expectationManager.stop(); + } + closeable.close(); + } + + @Test + void shouldCleanExpiredExpectationsWithCleanupDelay() { + Duration period = Duration.ofMillis(50); + Duration cleanupDelay = Duration.ofMillis(10); + expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + await() + .atMost(200, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> assertThat(expectationManager.isExpectationPresent(configMap)).isFalse()); + } + + @Test + void shouldCleanExpectationsWhenResourceNotInCache() { + Duration period = Duration.ofMillis(50); + expectationManager = new PeriodicCleanerExpectationManager<>(period, primaryCache); + + ResourceID resourceId = ResourceID.fromResource(configMap); + when(primaryCache.get(resourceId)).thenReturn(java.util.Optional.empty()); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(10)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + await() + .atMost(200, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> assertThat(expectationManager.isExpectationPresent(configMap)).isFalse()); + } + + @Test + void shouldNotCleanExpectationsWhenResourceInCache() throws InterruptedException { + Duration period = Duration.ofMillis(50); + expectationManager = new PeriodicCleanerExpectationManager<>(period, primaryCache); + + ResourceID resourceId = ResourceID.fromResource(configMap); + when(primaryCache.get(resourceId)).thenReturn(java.util.Optional.of(configMap)); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(10)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + Thread.sleep(150); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void shouldNotCleanNonExpiredExpectationsWithCleanupDelay() throws InterruptedException { + Duration period = Duration.ofMillis(50); + Duration cleanupDelay = Duration.ofMinutes(1); + expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + Thread.sleep(150); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void stopShouldShutdownScheduler() { + Duration period = Duration.ofMillis(50); + expectationManager = new PeriodicCleanerExpectationManager<>(period, Duration.ofMillis(10)); + + expectationManager.stop(); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void cleanShouldWorkDirectly() { + Duration period = Duration.ofMinutes(10); + Duration cleanupDelay = Duration.ofMillis(1); + expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + expectationManager.clean(); + + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } +} From a7fdb9178c6b413e288a2999e294a9ed37436c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 15:58:21 +0200 Subject: [PATCH 06/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationStatusTest.java | 35 ----------- .../expectation/ExpectationTest.java | 58 ------------------- 2 files changed, 93 deletions(-) delete mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java delete mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java deleted file mode 100644 index feba8cb651..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package io.javaoperatorsdk.operator.processing.expectation; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ExpectationStatusTest { - - @Test - void shouldHaveThreeStatuses() { - ExpectationStatus[] values = ExpectationStatus.values(); - - assertThat(values).hasSize(3); - assertThat(values) - .containsExactlyInAnyOrder( - ExpectationStatus.FULFILLED, - ExpectationStatus.NOT_FULFILLED, - ExpectationStatus.TIMED_OUT); - } - - @Test - void shouldHaveCorrectNames() { - assertThat(ExpectationStatus.FULFILLED.name()).isEqualTo("FULFILLED"); - assertThat(ExpectationStatus.NOT_FULFILLED.name()).isEqualTo("NOT_FULFILLED"); - assertThat(ExpectationStatus.TIMED_OUT.name()).isEqualTo("TIMED_OUT"); - } - - @Test - void shouldSupportValueOf() { - assertThat(ExpectationStatus.valueOf("FULFILLED")).isEqualTo(ExpectationStatus.FULFILLED); - assertThat(ExpectationStatus.valueOf("NOT_FULFILLED")) - .isEqualTo(ExpectationStatus.NOT_FULFILLED); - assertThat(ExpectationStatus.valueOf("TIMED_OUT")).isEqualTo(ExpectationStatus.TIMED_OUT); - } -} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java deleted file mode 100644 index 7e94994bc3..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.javaoperatorsdk.operator.processing.expectation; - -import org.junit.jupiter.api.Test; - -import io.fabric8.kubernetes.api.model.ConfigMap; -import io.javaoperatorsdk.operator.api.reconciler.Context; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -class ExpectationTest { - - @Test - void createExpectationWithCustomName() { - String customName = "test-expectation"; - Expectation expectation = - Expectation.createExpectation(customName, (primary, context) -> true); - - assertThat(expectation.name()).isEqualTo(customName); - } - - @Test - void createExpectationWithPredicate() { - ConfigMap configMap = new ConfigMap(); - Context context = mock(Context.class); - - Expectation trueExpectation = - Expectation.createExpectation("always-true", (primary, ctx) -> true); - Expectation falseExpectation = - Expectation.createExpectation("always-false", (primary, ctx) -> false); - - assertThat(trueExpectation.isFulfilled(configMap, context)).isTrue(); - assertThat(falseExpectation.isFulfilled(configMap, context)).isFalse(); - } - - @Test - void expectationShouldWorkWithGenericTypes() { - ConfigMap configMap = new ConfigMap(); - Context context = mock(Context.class); - - Expectation expectation = - new Expectation<>() { - @Override - public String name() { - return "custom-expectation"; - } - - @Override - public boolean isFulfilled(ConfigMap primary, Context context) { - return primary != null; - } - }; - - assertThat(expectation.name()).isEqualTo("custom-expectation"); - assertThat(expectation.isFulfilled(configMap, context)).isTrue(); - assertThat(expectation.isFulfilled(null, context)).isFalse(); - } -} From 7b8e100e2c22889d1b39fa3aed71dbebd1ed3db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 30 Sep 2025 10:01:03 +0200 Subject: [PATCH 07/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../ExpectationCustomResource.java | 12 ++++++++ .../baseapi/expectation/ExpectationIT.java | 16 +++++++++++ .../expectation/ExpectationReconciler.java | 28 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java new file mode 100644 index 0000000000..ea4b676653 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.baseapi.expectation; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("ecr") +public class ExpectationCustomResource extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java new file mode 100644 index 0000000000..c88fdbe2cc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.expectation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +class ExpectationIT { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(new ExpectationReconciler()).build(); + + @Test + void testExpectation() {} +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java new file mode 100644 index 0000000000..9e797ad2be --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.baseapi.expectation; + +import java.util.List; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.expectation.ExpectationManager; + +public class ExpectationReconciler implements Reconciler { + + ExpectationManager expectationManager = new ExpectationManager<>(); + + @Override + public UpdateControl reconcile( + ExpectationCustomResource resource, Context context) { + + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + return List.of(); + } +} From 6b128861162453e7c31ef73c95a2e4abd2ff1d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 16 Oct 2025 16:12:10 +0200 Subject: [PATCH 08/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Experimental.java | 5 + .../processing/expectation/Expectation.java | 17 +++ .../expectation/ExpectationManager.java | 91 +++++++++--- .../expectation/ExpectationResult.java | 23 +++ .../expectation/ExpectationStatus.java | 17 ++- .../PeriodicCleanerExpectationManager.java | 55 ++++---- .../expectation/RegisteredExpectation.java | 15 ++ .../expectation/ExpectationManagerTest.java | 92 +++++++----- ...PeriodicCleanerExpectationManagerTest.java | 87 +++--------- .../ExpectationCustomResource.java | 18 ++- .../ExpectationCustomResourceStatus.java | 29 ++++ .../baseapi/expectation/ExpectationIT.java | 57 +++++++- .../expectation/ExpectationReconciler.java | 132 +++++++++++++++++- 13 files changed, 488 insertions(+), 150 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResourceStatus.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java index bd9fe596e6..58c41da015 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java @@ -29,6 +29,11 @@ @Retention(RetentionPolicy.SOURCE) @Target({ElementType.METHOD, ElementType.TYPE, ElementType.FIELD, ElementType.PACKAGE}) public @interface Experimental { + /** + * Message for experimental features that we intend to keep and maintain, but + * the API might change usually, based on user feedback. + * */ + String API_MIGHT_CHANGE = "API might change, usually based on feedback"; /** * Describes why the annotated element is experimental. diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java index c9a026cd53..fbfd8d9699 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java @@ -1,10 +1,27 @@ +/* + * 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.processing.expectation; import java.util.function.BiPredicate; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; +@Experimental("based on feedback the API might change") public interface Expectation

{ String UNNAMED = "unnamed"; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java index f9f39d64d3..abb1993011 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -1,3 +1,18 @@ +/* + * 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.processing.expectation; import java.time.Duration; @@ -7,47 +22,87 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; + +@Experimental(API_MIGHT_CHANGE) public class ExpectationManager

{ protected final ConcurrentHashMap> registeredExpectations = new ConcurrentHashMap<>(); - public void setExpectation(P primary, Expectation

expectation, Duration timeout) { + public void setExpectation(P primary, Duration timeout, Expectation

expectation) { registeredExpectations.put( ResourceID.fromResource(primary), new RegisteredExpectation<>(LocalDateTime.now(), timeout, expectation)); } /** - * Checks if provided expectation is fulfilled. Return the expectation result. If the result of - * expectation is fulfilled or timed out, the expectation is automatically removed; + * Checks on expectation with provided name. Return the expectation result. If the result of + * expectation is fulfilled, the expectation is automatically removed; */ - public Optional> checkOnExpectation(P primary, Context

context) { + public ExpectationResult

checkExpectation( + String expectationName, P primary, Context

context) { var resourceID = ResourceID.fromResource(primary); - var regExp = registeredExpectations.get(ResourceID.fromResource(primary)); - if (regExp == null) { - return Optional.empty(); + var exp = registeredExpectations.get(ResourceID.fromResource(primary)); + if (exp != null && expectationName.equals(exp.expectation().name())) { + return checkExpectation(exp, resourceID, primary, context); + } else { + return checkExpectation(null, resourceID, primary, context); } - if (regExp.expectation().isFulfilled(primary, context)) { - registeredExpectations.remove(resourceID); - return Optional.of( - new ExpectationResult<>(regExp.expectation(), ExpectationStatus.FULFILLED)); - } else if (regExp.isTimedOut()) { + } + + /** + * Checks if actual expectation is fulfilled. Return the expectation result. If the result of + * expectation is fulfilled, the expectation is automatically removed; + */ + public ExpectationResult

checkExpectation(P primary, Context

context) { + var resourceID = ResourceID.fromResource(primary); + var exp = registeredExpectations.get(ResourceID.fromResource(primary)); + return checkExpectation(exp, resourceID, primary, context); + } + + private ExpectationResult

checkExpectation( + RegisteredExpectation

exp, ResourceID resourceID, P primary, Context

context) { + if (exp == null) { + return new ExpectationResult<>(null, null); + } + if (exp.expectation().isFulfilled(primary, context)) { registeredExpectations.remove(resourceID); - return Optional.of( - new ExpectationResult<>(regExp.expectation(), ExpectationStatus.TIMED_OUT)); + return new ExpectationResult<>(exp.expectation(), ExpectationStatus.FULFILLED); + } else if (exp.isTimedOut()) { + // we don't remove the expectation so user knows about it's state + return new ExpectationResult<>(exp.expectation(), ExpectationStatus.TIMED_OUT); } else { - return Optional.of( - new ExpectationResult<>(regExp.expectation(), ExpectationStatus.NOT_FULFILLED)); + return new ExpectationResult<>(exp.expectation(), ExpectationStatus.NOT_YET_FULFILLED); + } + } + + /* + * Returns true if there is an expectation for the primary resource, but it is not yet fulfilled + * neither timed out. + * The intention behind is that you can exit reconciliation early with a simple check + * if true. + * */ + public boolean ongoingExpectationPresent(P primary, Context

context) { + var exp = registeredExpectations.get(ResourceID.fromResource(primary)); + if (exp == null) { + return false; } + return !exp.isTimedOut() && !exp.expectation().isFulfilled(primary, context); } public boolean isExpectationPresent(P primary) { return registeredExpectations.containsKey(ResourceID.fromResource(primary)); } + public boolean isExpectationPresent(String name, P primary) { + var exp = registeredExpectations.get(ResourceID.fromResource(primary)); + return exp != null && name.equals(exp.expectation().name()); + } + public Optional> getExpectation(P primary) { var regExp = registeredExpectations.get(ResourceID.fromResource(primary)); return Optional.ofNullable(regExp).map(RegisteredExpectation::expectation); @@ -57,6 +112,10 @@ public Optional getExpectationName(P primary) { return getExpectation(primary).map(Expectation::name); } + public void removeExpectation(P primary) { + registeredExpectations.remove(ResourceID.fromResource(primary)); + } + public void cleanup(P primary) { registeredExpectations.remove(ResourceID.fromResource(primary)); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java index 408050421a..5b4081f7b3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java @@ -1,3 +1,18 @@ +/* + * 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.processing.expectation; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -13,6 +28,14 @@ public boolean isTimedOut() { return status == ExpectationStatus.TIMED_OUT; } + public boolean isExpectationPresent() { + return expectation != null; + } + + public boolean isNotPresentOrFulfilled() { + return !isExpectationPresent() || isFulfilled(); + } + public String name() { return expectation.name(); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java index 55ee791b9d..7b6df29fad 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java @@ -1,7 +1,22 @@ +/* + * 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.processing.expectation; public enum ExpectationStatus { FULFILLED, - NOT_FULFILLED, + NOT_YET_FULFILLED, TIMED_OUT } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java index 5478141e22..d2145e2afe 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java @@ -1,17 +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.processing.expectation; import java.time.Duration; -import java.time.LocalDateTime; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; + +/** + * Expectation manager implementation that works without enabling {@link + * ControllerConfiguration#triggerReconcilerOnAllEvent()}. Periodically checks and cleanups' + * expectations for primary resources which are no longer present in the cache. + */ +@Experimental(API_MIGHT_CHANGE) public class PeriodicCleanerExpectationManager

extends ExpectationManager

{ + public static final Duration DEFAULT_CHECK_PERIOD = Duration.ofMinutes(1); + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool( 1, @@ -21,41 +47,20 @@ public class PeriodicCleanerExpectationManager

return thread; }); - private final Duration cleanupDelayAfterExpiration; private final IndexedResourceCache

primaryCache; - public PeriodicCleanerExpectationManager(Duration period, Duration cleanupDelayAfterExpiration) { - this(period, cleanupDelayAfterExpiration, null); + public PeriodicCleanerExpectationManager(IndexedResourceCache

primaryCache) { + this(DEFAULT_CHECK_PERIOD, primaryCache); } public PeriodicCleanerExpectationManager(Duration period, IndexedResourceCache

primaryCache) { - this(period, null, primaryCache); - } - - private PeriodicCleanerExpectationManager( - Duration period, Duration cleanupDelayAfterExpiration, IndexedResourceCache

primaryCache) { - this.cleanupDelayAfterExpiration = cleanupDelayAfterExpiration; this.primaryCache = primaryCache; scheduler.scheduleWithFixedDelay( this::clean, period.toMillis(), period.toMillis(), TimeUnit.MICROSECONDS); } public void clean() { - registeredExpectations - .entrySet() - .removeIf( - e -> { - if (cleanupDelayAfterExpiration != null) { - return LocalDateTime.now() - .isAfter( - e.getValue() - .registeredAt() - .plus(e.getValue().timeout()) - .plus(cleanupDelayAfterExpiration)); - } else { - return primaryCache.get(e.getKey()).isEmpty(); - } - }); + registeredExpectations.entrySet().removeIf(e -> primaryCache.get(e.getKey()).isEmpty()); } void stop() { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java index fe24f6dd25..b22a810d6c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java @@ -1,3 +1,18 @@ +/* + * 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.processing.expectation; import java.time.Duration; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java index 399ea2652f..3b7499a9d3 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java @@ -1,3 +1,18 @@ +/* + * 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.processing.expectation; import java.time.Duration; @@ -11,6 +26,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -34,64 +50,59 @@ void setExpectationShouldStoreExpectation() { Expectation expectation = mock(Expectation.class); Duration timeout = Duration.ofMinutes(5); - expectationManager.setExpectation(configMap, expectation, timeout); + expectationManager.setExpectation(configMap, timeout, expectation); assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); assertThat(expectationManager.getExpectation(configMap)).contains(expectation); } @Test - void checkOnExpectationShouldReturnEmptyWhenNoExpectation() { - Optional> result = - expectationManager.checkOnExpectation(configMap, context); + void checkExpectationShouldReturnEmptyWhenNoExpectation() { + ExpectationResult result = expectationManager.checkExpectation(configMap, context); - assertThat(result).isEmpty(); + assertThat(result.isExpectationPresent()).isFalse(); } @Test - void checkOnExpectationShouldReturnFulfilledWhenExpectationMet() { + void checkExpectationShouldReturnFulfilledWhenExpectationMet() { Expectation expectation = mock(Expectation.class); when(expectation.isFulfilled(configMap, context)).thenReturn(true); - expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); - Optional> result = - expectationManager.checkOnExpectation(configMap, context); + expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation); + ExpectationResult result = expectationManager.checkExpectation(configMap, context); - assertThat(result).isPresent(); - assertThat(result.get().status()).isEqualTo(ExpectationStatus.FULFILLED); - assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(result.isExpectationPresent()).isTrue(); + assertThat(result.isFulfilled()).isTrue(); + assertThat(result.expectation()).isEqualTo(expectation); assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); } @Test - void checkOnExpectationShouldReturnNotFulfilledWhenExpectationNotMet() { + void checkExpectationShouldReturnNotFulfilledWhenExpectationNotMet() { Expectation expectation = mock(Expectation.class); when(expectation.isFulfilled(configMap, context)).thenReturn(false); - expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); - Optional> result = - expectationManager.checkOnExpectation(configMap, context); + expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation); + ExpectationResult result = expectationManager.checkExpectation(configMap, context); - assertThat(result).isPresent(); - assertThat(result.get().status()).isEqualTo(ExpectationStatus.NOT_FULFILLED); - assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(result.isExpectationPresent()).isTrue(); + assertThat(result.isFulfilled()).isFalse(); + assertThat(result.expectation()).isEqualTo(expectation); assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); } @Test - void checkOnExpectationShouldReturnTimedOutWhenExpectationExpired() throws InterruptedException { + void checkExpectationShouldReturnTimedOutWhenExpectationExpired() throws InterruptedException { Expectation expectation = mock(Expectation.class); when(expectation.isFulfilled(configMap, context)).thenReturn(false); - expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + expectationManager.setExpectation(configMap, Duration.ofMillis(1), expectation); Thread.sleep(10); - Optional> result = - expectationManager.checkOnExpectation(configMap, context); + ExpectationResult result = expectationManager.checkExpectation(configMap, context); - assertThat(result).isPresent(); - assertThat(result.get().status()).isEqualTo(ExpectationStatus.TIMED_OUT); - assertThat(result.get().expectation()).isEqualTo(expectation); - assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + assertThat(result.isExpectationPresent()).isTrue(); + assertThat(result.isTimedOut()).isTrue(); + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); } @Test @@ -100,7 +111,7 @@ void getExpectationNameShouldReturnExpectationName() { Expectation expectation = mock(Expectation.class); when(expectation.name()).thenReturn(expectedName); - expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation); Optional name = expectationManager.getExpectationName(configMap); assertThat(name).contains(expectedName); @@ -117,13 +128,28 @@ void getExpectationNameShouldReturnEmptyWhenNoExpectation() { void cleanupShouldRemoveExpectation() { Expectation expectation = mock(Expectation.class); - expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation); assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); expectationManager.cleanup(configMap); assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); } + @Test + void checkingSpecificExpectation() { + String expectedName = "test-expectation"; + Expectation expectation = mock(Expectation.class); + when(expectation.name()).thenReturn(expectedName); + when(expectation.isFulfilled(any(), any())).thenReturn(true); + + expectationManager.setExpectation(configMap, Duration.ofMinutes(1), expectation); + + var res = expectationManager.checkExpectation("other-expectation", configMap, context); + assertThat(res.isExpectationPresent()).isFalse(); + res = expectationManager.checkExpectation(expectedName, configMap, context); + assertThat(res.isExpectationPresent()).isTrue(); + } + @Test void shouldHandleMultipleExpectationsForDifferentResources() { ConfigMap configMap2 = new ConfigMap(); @@ -136,8 +162,8 @@ void shouldHandleMultipleExpectationsForDifferentResources() { Expectation expectation1 = mock(Expectation.class); Expectation expectation2 = mock(Expectation.class); - expectationManager.setExpectation(configMap, expectation1, Duration.ofMinutes(5)); - expectationManager.setExpectation(configMap2, expectation2, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation1); + expectationManager.setExpectation(configMap2, Duration.ofMinutes(5), expectation2); assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); assertThat(expectationManager.isExpectationPresent(configMap2)).isTrue(); @@ -150,8 +176,8 @@ void setExpectationShouldReplaceExistingExpectation() { Expectation expectation1 = mock(Expectation.class); Expectation expectation2 = mock(Expectation.class); - expectationManager.setExpectation(configMap, expectation1, Duration.ofMinutes(5)); - expectationManager.setExpectation(configMap, expectation2, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation1); + expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation2); assertThat(expectationManager.getExpectation(configMap)).contains(expectation2); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java index 0bba070955..a317d1aaa7 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java @@ -1,3 +1,18 @@ +/* + * 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.processing.expectation; import java.time.Duration; @@ -42,23 +57,6 @@ void tearDown() throws Exception { closeable.close(); } - @Test - void shouldCleanExpiredExpectationsWithCleanupDelay() { - Duration period = Duration.ofMillis(50); - Duration cleanupDelay = Duration.ofMillis(10); - expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); - - Expectation expectation = (primary, context) -> false; - expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); - - assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); - - await() - .atMost(200, TimeUnit.MILLISECONDS) - .untilAsserted( - () -> assertThat(expectationManager.isExpectationPresent(configMap)).isFalse()); - } - @Test void shouldCleanExpectationsWhenResourceNotInCache() { Duration period = Duration.ofMillis(50); @@ -68,7 +66,7 @@ void shouldCleanExpectationsWhenResourceNotInCache() { when(primaryCache.get(resourceId)).thenReturn(java.util.Optional.empty()); Expectation expectation = (primary, context) -> false; - expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(10)); + expectationManager.setExpectation(configMap, Duration.ofMinutes(10), expectation); assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); @@ -87,7 +85,7 @@ void shouldNotCleanExpectationsWhenResourceInCache() throws InterruptedException when(primaryCache.get(resourceId)).thenReturn(java.util.Optional.of(configMap)); Expectation expectation = (primary, context) -> false; - expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(10)); + expectationManager.setExpectation(configMap, Duration.ofMinutes(10), expectation); assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); @@ -95,55 +93,4 @@ void shouldNotCleanExpectationsWhenResourceInCache() throws InterruptedException assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); } - - @Test - void shouldNotCleanNonExpiredExpectationsWithCleanupDelay() throws InterruptedException { - Duration period = Duration.ofMillis(50); - Duration cleanupDelay = Duration.ofMinutes(1); - expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); - - Expectation expectation = (primary, context) -> false; - expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); - - assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); - - Thread.sleep(150); - - assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); - } - - @Test - void stopShouldShutdownScheduler() { - Duration period = Duration.ofMillis(50); - expectationManager = new PeriodicCleanerExpectationManager<>(period, Duration.ofMillis(10)); - - expectationManager.stop(); - - Expectation expectation = (primary, context) -> false; - expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); - - assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); - } - - @Test - void cleanShouldWorkDirectly() { - Duration period = Duration.ofMinutes(10); - Duration cleanupDelay = Duration.ofMillis(1); - expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); - - Expectation expectation = (primary, context) -> false; - expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); - - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - - assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); - - expectationManager.clean(); - - assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); - } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java index ea4b676653..4568550b9e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java @@ -1,3 +1,18 @@ +/* + * 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.expectation; import io.fabric8.kubernetes.api.model.Namespaced; @@ -9,4 +24,5 @@ @Group("sample.javaoperatorsdk") @Version("v1") @ShortNames("ecr") -public class ExpectationCustomResource extends CustomResource implements Namespaced {} +public class ExpectationCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResourceStatus.java new file mode 100644 index 0000000000..f6ae538d4f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResourceStatus.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.expectation; + +public class ExpectationCustomResourceStatus { + + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java index c88fdbe2cc..234f3b3949 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java @@ -1,16 +1,71 @@ +/* + * 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.expectation; 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 io.javaoperatorsdk.operator.baseapi.expectation.ExpectationReconciler.DEPLOYMENT_READY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + class ExpectationIT { + public static final String TEST_1 = "test1"; + @RegisterExtension LocallyRunOperatorExtension extension = LocallyRunOperatorExtension.builder().withReconciler(new ExpectationReconciler()).build(); @Test - void testExpectation() {} + void testExpectation() { + extension.getReconcilerOfType(ExpectationReconciler.class).setTimeout(30000L); + var res = testResource(); + extension.create(res); + + await() + .untilAsserted( + () -> { + var actual = extension.get(ExpectationCustomResource.class, TEST_1); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getMessage()).isEqualTo(DEPLOYMENT_READY); + }); + } + + @Test + void expectationTimeouts() { + extension.getReconcilerOfType(ExpectationReconciler.class).setTimeout(300L); + var res = testResource(); + extension.create(res); + + await() + .untilAsserted( + () -> { + var actual = extension.get(ExpectationCustomResource.class, TEST_1); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getMessage()).isEqualTo(DEPLOYMENT_READY); + }); + } + + private ExpectationCustomResource testResource() { + var res = new ExpectationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_1).build()); + return res; + } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java index 9e797ad2be..e206abd9fa 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java @@ -1,28 +1,154 @@ +/* + * 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.expectation; +import java.time.Duration; import java.util.List; +import java.util.Map; +import io.fabric8.kubernetes.api.model.ContainerBuilder; +import io.fabric8.kubernetes.api.model.ContainerPortBuilder; +import io.fabric8.kubernetes.api.model.LabelSelectorBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.PodSpecBuilder; +import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.api.model.apps.DeploymentSpecBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.expectation.Expectation; import io.javaoperatorsdk.operator.processing.expectation.ExpectationManager; +@ControllerConfiguration(triggerReconcilerOnAllEvent = true) public class ExpectationReconciler implements Reconciler { - ExpectationManager expectationManager = new ExpectationManager<>(); + public static final String DEPLOYMENT_READY = "Deployment ready"; + public static final String DEPLOYMENT_TIMEOUT = "Deployment timeout"; + public static final String DEPLOYMENT_READY_EXPECTATION_NAME = "deploymentReadyExpectation"; + private final ExpectationManager expectationManager = + new ExpectationManager<>(); + + private volatile Long timeout = 30000l; @Override public UpdateControl reconcile( - ExpectationCustomResource resource, Context context) { + ExpectationCustomResource primary, Context context) { + + // cleans up expectation manager for the resource on delete event + // in case of cleaner interface used, this can done also there. + if (context.isPrimaryResourceDeleted()) { + expectationManager.cleanup(primary); + } + + // exiting asap if there is an expectation that is not timed out neither fulfilled + if (expectationManager.ongoingExpectationPresent(primary, context)) { + return UpdateControl.noUpdate(); + } + var deployment = context.getSecondaryResource(Deployment.class); + if (deployment.isEmpty()) { + createDeployment(primary, context); + expectationManager.setExpectation( + primary, Duration.ofSeconds(timeout), deploymentReadyExpectation(context)); + return UpdateControl.noUpdate(); + } else { + var res = expectationManager.checkExpectation(primary, context); + if (res.isFulfilled()) { + return pathStatusWithMessage(primary, DEPLOYMENT_READY); + } else if (res.isTimedOut()) { + return pathStatusWithMessage(primary, DEPLOYMENT_TIMEOUT); + } + } return UpdateControl.noUpdate(); } + private static UpdateControl pathStatusWithMessage( + ExpectationCustomResource primary, String message) { + primary.setStatus(new ExpectationCustomResourceStatus()); + primary.getStatus().setMessage(message); + return UpdateControl.patchStatus(primary); + } + + private static Expectation deploymentReadyExpectation( + Context context) { + return Expectation.createExpectation( + DEPLOYMENT_READY_EXPECTATION_NAME, + (p, c) -> { + var actualDeployment = context.getSecondaryResource(Deployment.class).orElseThrow(); + return actualDeployment.getStatus() != null + && actualDeployment.getStatus().getReadyReplicas() != null + && actualDeployment.getStatus().getReadyReplicas() == 3; + }); + } + + private Deployment createDeployment( + ExpectationCustomResource primary, Context context) { + var d = + new DeploymentBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withSpec( + new DeploymentSpecBuilder() + .withReplicas(3) + .withSelector( + new LabelSelectorBuilder().withMatchLabels(Map.of("app", "nginx")).build()) + .withTemplate( + new PodTemplateSpecBuilder() + .withMetadata( + new ObjectMetaBuilder().withLabels(Map.of("app", "nginx")).build()) + .withSpec( + new PodSpecBuilder() + .withContainers( + new ContainerBuilder() + .withName("nginx") + .withImage("nginx:1.29.2") + .withPorts( + new ContainerPortBuilder() + .withContainerPort(80) + .build()) + .build()) + .build()) + .build()) + .build()) + .build(); + d.addOwnerReference(primary); + return context.getClient().resource(d).serverSideApply(); + } + @Override public List> prepareEventSources( EventSourceContext context) { - return List.of(); + return List.of( + new InformerEventSource<>( + InformerEventSourceConfiguration.from(Deployment.class, ExpectationCustomResource.class) + .build(), + context)); + } + + public void setTimeout(Long timeout) { + this.timeout = timeout; } } From 5c5a3c01a4878afa7aa65c3b93edab487e987ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 16 Oct 2025 16:14:57 +0200 Subject: [PATCH 09/12] format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Experimental.java | 6 +++--- .../operator/processing/expectation/Expectation.java | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java index 58c41da015..963c56b47b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java @@ -30,9 +30,9 @@ @Target({ElementType.METHOD, ElementType.TYPE, ElementType.FIELD, ElementType.PACKAGE}) public @interface Experimental { /** - * Message for experimental features that we intend to keep and maintain, but - * the API might change usually, based on user feedback. - * */ + * Message for experimental features that we intend to keep and maintain, but the API might change + * usually, based on user feedback. + */ String API_MIGHT_CHANGE = "API might change, usually based on feedback"; /** diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java index fbfd8d9699..f3d53c3188 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java @@ -21,7 +21,9 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.Experimental; -@Experimental("based on feedback the API might change") +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; + +@Experimental(API_MIGHT_CHANGE) public interface Expectation

{ String UNNAMED = "unnamed"; From 246035cdfe135768e7192109f912bda93905a772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 16 Oct 2025 16:40:04 +0200 Subject: [PATCH 10/12] docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../en/docs/documentation/reconciler.md | 61 ++++++++++++++++++- .../expectation/ExpectationReconciler.java | 15 +++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index 7af6527422..62727b84ba 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -258,4 +258,63 @@ In this mode: - you cannot use managed dependent resources since those manage the finalizers and other logic related to the normal execution mode. -See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling) for selectively adding finalizers for resources; \ No newline at end of file +See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling) for selectively adding finalizers for resources; + +### Expectations + +Expectations are a pattern to make sure to check in the reconciliation that your secondary resources are in a certain state. +For a more detailed explanation see [this blogpost](https://ahmet.im/blog/controller-pitfalls/#expectations-pattern). +You can find framework support for this pattern in [`io.javaoperatorsdk.operator.processing.expectation`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/) +package. See also related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java). +Note that this feature is marked as `@Experimental`, since based on feedback the API might be improved / changed, but we intend +to support it, later also might be integrated to Dependent Resources and/or Workflows. + +The idea is the nutshell, is that you can track your expectations in the expectation manager in the reconciler. +Which has an api that covers the common use cases. + +The following sample is the simplified version of the integration tests that implements a logic that creates a +deployment and sets status message if there are the target three replicas ready: + +```java +public class ExpectationReconciler implements Reconciler { + + // some code is omitted + + private final ExpectationManager expectationManager = + new ExpectationManager<>(); + + @Override + public UpdateControl reconcile( + ExpectationCustomResource primary, Context context) { + + // exiting asap if there is an expectation that is not timed out neither fulfilled yet + if (expectationManager.ongoingExpectationPresent(primary, context)) { + return UpdateControl.noUpdate(); + } + + var deployment = context.getSecondaryResource(Deployment.class); + if (deployment.isEmpty()) { + createDeployment(primary, context); + expectationManager.setExpectation( + primary, Duration.ofSeconds(timeout), deploymentReadyExpectation(context)); + return UpdateControl.noUpdate(); + } else { + // checks the expectation if it is fulfilled also removes it, + // in your logic you might add a next expectation based on your workflow. + // Expectations have a name, so you can easily distinguish them if there is more of them. + var res = expectationManager.checkExpectation("deploymentReadyExpectation",primary, context); + if (res.isFulfilled()) { + return pathchStatusWithMessage(primary, DEPLOYMENT_READY); + } else if (res.isTimedOut()) { + // you might add some other timeout handling here + return pathchStatusWithMessage(primary, DEPLOYMENT_TIMEOUT); + } + } + return UpdateControl.noUpdate(); + + } +} +``` + + + diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java index e206abd9fa..6b3fbe64da 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java @@ -72,17 +72,24 @@ public UpdateControl reconcile( primary, Duration.ofSeconds(timeout), deploymentReadyExpectation(context)); return UpdateControl.noUpdate(); } else { - var res = expectationManager.checkExpectation(primary, context); + // checks the expectation if it is fulfilled also removes it, + // in your logic you might add a next expectation based on your workflow + // Expectations have a name, so you can easily distinguish them if there is more of them. + var res = + expectationManager.checkExpectation(DEPLOYMENT_READY_EXPECTATION_NAME, primary, context); + // Note that this happens only once, since if the expectation is fulfilled, it is also removed + // from the manager. if (res.isFulfilled()) { - return pathStatusWithMessage(primary, DEPLOYMENT_READY); + return pathchStatusWithMessage(primary, DEPLOYMENT_READY); } else if (res.isTimedOut()) { - return pathStatusWithMessage(primary, DEPLOYMENT_TIMEOUT); + // you might add some other timeout handling here + return pathchStatusWithMessage(primary, DEPLOYMENT_TIMEOUT); } } return UpdateControl.noUpdate(); } - private static UpdateControl pathStatusWithMessage( + private static UpdateControl pathchStatusWithMessage( ExpectationCustomResource primary, String message) { primary.setStatus(new ExpectationCustomResourceStatus()); primary.getStatus().setMessage(message); From 2fcaf66a1409269f6c1e85043fd1bc65e87a30d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 20 Oct 2025 17:45:29 +0200 Subject: [PATCH 11/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationManager.java | 25 +++++++++++++++++++ .../expectation/ExpectationManagerTest.java | 24 ++++++++++++++++++ .../expectation/ExpectationReconciler.java | 9 ++++--- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java index abb1993011..6ef274441c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -33,6 +33,31 @@ public class ExpectationManager

{ protected final ConcurrentHashMap> registeredExpectations = new ConcurrentHashMap<>(); + /** + * Checks if the expectation holds, if not sets the expectation with the given timeout. + * + * @return false, if the expectation is already fulfilled, therefore, not registered. Returns true + * if expectation is not met and set with a timeout. + */ + public boolean checkAndSetExpectation( + P primary, Context

context, Duration timeout, Expectation

expectation) { + var fulfilled = expectation.isFulfilled(primary, context); + if (fulfilled) { + return false; + } else { + setExpectation(primary, timeout, expectation); + return true; + } + } + + /** + * Sets a target expectation with given timeout. + * + * @param primary resource + * @param timeout of expectation + * @param expectation to check + */ + // we might consider in the future to throw an exception if an expectation is already set public void setExpectation(P primary, Duration timeout, Expectation

expectation) { registeredExpectations.put( ResourceID.fromResource(primary), diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java index 3b7499a9d3..ff1136338e 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java @@ -181,4 +181,28 @@ void setExpectationShouldReplaceExistingExpectation() { assertThat(expectationManager.getExpectation(configMap)).contains(expectation2); } + + @Test + void checkAndSetExpectationAlreadyMet() { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(any(), any())).thenReturn(true); + + var res = + expectationManager.checkAndSetExpectation( + configMap, mock(Context.class), Duration.ofMinutes(5), expectation); + assertThat(res).isFalse(); + assertThat(expectationManager.getExpectation(configMap)).isEmpty(); + } + + @Test + void checkAndSetExpectationNotMet() { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(any(), any())).thenReturn(false); + + var res = + expectationManager.checkAndSetExpectation( + configMap, mock(Context.class), Duration.ofMinutes(5), expectation); + assertThat(res).isTrue(); + assertThat(expectationManager.getExpectation(configMap)).isPresent(); + } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java index 6b3fbe64da..de8869b9ad 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java @@ -68,9 +68,12 @@ public UpdateControl reconcile( var deployment = context.getSecondaryResource(Deployment.class); if (deployment.isEmpty()) { createDeployment(primary, context); - expectationManager.setExpectation( - primary, Duration.ofSeconds(timeout), deploymentReadyExpectation(context)); - return UpdateControl.noUpdate(); + var set = + expectationManager.checkAndSetExpectation( + primary, context, Duration.ofSeconds(timeout), deploymentReadyExpectation(context)); + if (set) { + return UpdateControl.noUpdate(); + } } else { // checks the expectation if it is fulfilled also removes it, // in your logic you might add a next expectation based on your workflow From 603ac9d4a97868c57e4a718003293329afedee65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 23 Oct 2025 13:10:25 +0200 Subject: [PATCH 12/12] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationReconciler.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java index de8869b9ad..44db8b2c8d 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java @@ -103,12 +103,15 @@ private static Expectation deploymentReadyExpectation Context context) { return Expectation.createExpectation( DEPLOYMENT_READY_EXPECTATION_NAME, - (p, c) -> { - var actualDeployment = context.getSecondaryResource(Deployment.class).orElseThrow(); - return actualDeployment.getStatus() != null - && actualDeployment.getStatus().getReadyReplicas() != null - && actualDeployment.getStatus().getReadyReplicas() == 3; - }); + (p, c) -> + context + .getSecondaryResource(Deployment.class) + .map( + ad -> + ad.getStatus() != null + && ad.getStatus().getReadyReplicas() != null + && ad.getStatus().getReadyReplicas() == 3) + .orElse(false)); } private Deployment createDeployment(