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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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( From ab91daf7bcf860035d87f049a57f79cebcda53e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 1 Nov 2025 01:08:10 +0100 Subject: [PATCH 13/18] Update docs/content/en/docs/documentation/reconciler.md Co-authored-by: Martin Stefanko --- docs/content/en/docs/documentation/reconciler.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index 62727b84ba..56b530daf8 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -262,7 +262,7 @@ See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/m ### Expectations -Expectations are a pattern to make sure to check in the reconciliation that your secondary resources are in a certain state. +Expectations are a pattern to ensure that, during reconciliation, 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). From c16dabf8dc90b15b3ed0d533aa8d35ec7d9a9fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 1 Nov 2025 01:08:22 +0100 Subject: [PATCH 14/18] Update docs/content/en/docs/documentation/reconciler.md Co-authored-by: Martin Stefanko --- docs/content/en/docs/documentation/reconciler.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index 56b530daf8..9186a1a118 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -270,7 +270,7 @@ Note that this feature is marked as `@Experimental`, since based on feedback the 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. +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: From 46819d9e4cf3ebc5dac31d0b4634ff2af6b8c588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 1 Nov 2025 01:08:31 +0100 Subject: [PATCH 15/18] Update docs/content/en/docs/documentation/reconciler.md Co-authored-by: Martin Stefanko --- docs/content/en/docs/documentation/reconciler.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index 9186a1a118..beb229177c 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -269,7 +269,7 @@ package. See also related [integration test](https://github.com/operator-framewo 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. +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 From 61346834b16097be41e5f236b2b6a018319c0680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 1 Nov 2025 01:08:38 +0100 Subject: [PATCH 16/18] Update docs/content/en/docs/documentation/reconciler.md Co-authored-by: Martin Stefanko --- docs/content/en/docs/documentation/reconciler.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index beb229177c..2b1200d955 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -272,7 +272,7 @@ to support it, later also might be integrated to Dependent Resources and/or Work 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 +The following sample is the simplified version of the integration test that implements the logic that creates a deployment and sets status message if there are the target three replicas ready: ```java From 57a8eccb71b76ceecd61ca379cfc669cd353764d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 1 Nov 2025 01:08:47 +0100 Subject: [PATCH 17/18] Update docs/content/en/docs/documentation/reconciler.md Co-authored-by: Martin Stefanko --- docs/content/en/docs/documentation/reconciler.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index 2b1200d955..a60ddd2b12 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -299,7 +299,7 @@ public class ExpectationReconciler implements Reconciler Date: Sat, 1 Nov 2025 01:21:59 +0100 Subject: [PATCH 18/18] changes from code review 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 | 4 ---- .../processing/expectation/ExpectationManagerTest.java | 4 ++-- .../operator/baseapi/expectation/ExpectationReconciler.java | 2 +- 3 files changed, 3 insertions(+), 7 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 6ef274441c..1c2f9d8ed3 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 @@ -140,8 +140,4 @@ public Optional getExpectationName(P primary) { 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/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java index ff1136338e..1a69e4057b 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 @@ -125,13 +125,13 @@ void getExpectationNameShouldReturnEmptyWhenNoExpectation() { } @Test - void cleanupShouldRemoveExpectation() { + void removeExpectationShouldRemoveExpectation() { Expectation expectation = mock(Expectation.class); expectationManager.setExpectation(configMap, Duration.ofMinutes(5), expectation); assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); - expectationManager.cleanup(configMap); + expectationManager.removeExpectation(configMap); assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); } 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 44db8b2c8d..3a57d3effd 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 @@ -57,7 +57,7 @@ public UpdateControl reconcile( // 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); + expectationManager.removeExpectation(primary); } // exiting asap if there is an expectation that is not timed out neither fulfilled