From 6c6dab202dae875d544af9f25b81972bc0ee8664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 12 Dec 2025 14:58:46 +0100 Subject: [PATCH] improve: add integration test for PeriodicCleanerExpectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- ...iodicCleanerExpectationCustomResource.java | 29 +++ ...leanerExpectationCustomResourceStatus.java | 29 +++ .../PeriodicCleanerExpectationIT.java | 117 +++++++++++ .../PeriodicCleanerExpectationReconciler.java | 194 ++++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResourceStatus.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationReconciler.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResource.java new file mode 100644 index 0000000000..42b07be945 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResource.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; + +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("pcecr") +public class PeriodicCleanerExpectationCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResourceStatus.java new file mode 100644 index 0000000000..49fd684949 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationCustomResourceStatus.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 PeriodicCleanerExpectationCustomResourceStatus { + + 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/PeriodicCleanerExpectationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationIT.java new file mode 100644 index 0000000000..293fa15a7f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationIT.java @@ -0,0 +1,117 @@ +/* + * 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 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.PeriodicCleanerExpectationReconciler.DEPLOYMENT_READY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration test showcasing PeriodicCleanerExpectationManager usage. + * + *

This test demonstrates the key benefits of PeriodicCleanerExpectationManager: 1. Works without + * requiring @ControllerConfiguration(triggerReconcilerOnAllEvents = true) 2. Automatically cleans + * up stale expectations periodically 3. Maintains the same expectation API and functionality as the + * regular ExpectationManager + */ +class PeriodicCleanerExpectationIT { + + public static final String TEST_1 = "test1"; + public static final String TEST_2 = "test2"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new PeriodicCleanerExpectationReconciler()) + .build(); + + @Test + void testPeriodicCleanerExpectationBasicFlow() { + extension + .getReconcilerOfType(PeriodicCleanerExpectationReconciler.class) + .setTimeout(Duration.ofSeconds(30)); + var res = testResource(); + extension.create(res); + + await() + .untilAsserted( + () -> { + var actual = extension.get(PeriodicCleanerExpectationCustomResource.class, TEST_1); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getMessage()).isEqualTo(DEPLOYMENT_READY); + }); + } + + @Test + void testPeriodicCleanerExpectationTimeouts() { + extension + .getReconcilerOfType(PeriodicCleanerExpectationReconciler.class) + .setTimeout(Duration.ofMillis(300)); + var res = testResource(); + extension.create(res); + + await() + .untilAsserted( + () -> { + var actual = extension.get(PeriodicCleanerExpectationCustomResource.class, TEST_1); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getMessage()).isEqualTo(DEPLOYMENT_READY); + }); + } + + @Test + void demonstratesNoTriggerReconcilerOnAllEventsNeeded() { + // This test demonstrates that PeriodicCleanerExpectationManager works + // without @ControllerConfiguration(triggerReconcilerOnAllEvents = true) + + // The PeriodicCleanerExpectationReconciler doesn't use triggerReconcilerOnAllEvents = true + // yet expectations still work properly due to the periodic cleanup functionality + + var reconciler = extension.getReconcilerOfType(PeriodicCleanerExpectationReconciler.class); + reconciler.setTimeout(Duration.ofSeconds(30)); + + var res = testResource("no-trigger-test"); + extension.create(res); + + // Verify that expectations work even without triggerReconcilerOnAllEvents = true + await() + .untilAsserted( + () -> { + var actual = + extension.get(PeriodicCleanerExpectationCustomResource.class, "no-trigger-test"); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getMessage()).isEqualTo(DEPLOYMENT_READY); + }); + } + + private PeriodicCleanerExpectationCustomResource testResource() { + return testResource(TEST_1); + } + + private PeriodicCleanerExpectationCustomResource testResource(String name) { + var res = new PeriodicCleanerExpectationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationReconciler.java new file mode 100644 index 0000000000..4802d5da86 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/PeriodicCleanerExpectationReconciler.java @@ -0,0 +1,194 @@ +/* + * 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 java.util.concurrent.atomic.AtomicReference; + +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.PeriodicCleanerExpectationManager; + +/** + * Integration test reconciler showcasing PeriodicCleanerExpectationManager usage. + * + *

Key differences from ExpectationReconciler: - Uses PeriodicCleanerExpectationManager instead + * of ExpectationManager - Does NOT use @ControllerConfiguration(triggerReconcilerOnAllEvents = + * true) - Demonstrates periodic cleanup functionality without requiring reconciler triggers on all + * events + */ +@ControllerConfiguration +public class PeriodicCleanerExpectationReconciler + implements Reconciler { + + 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 PeriodicCleanerExpectationManager + expectationManager; + private final AtomicReference timeoutRef = + new AtomicReference<>(Duration.ofSeconds(30)); + + public PeriodicCleanerExpectationReconciler() { + // expectationManager will be initialized in prepareEventSources when cache is available + } + + public void setTimeout(Duration timeout) { + timeoutRef.set(timeout); + } + + public PeriodicCleanerExpectationManager + getExpectationManager() { + return expectationManager; + } + + @Override + public UpdateControl reconcile( + PeriodicCleanerExpectationCustomResource primary, + Context context) { + + // Note: Unlike regular ExpectationManager, we don't need to manually clean up on delete + // because PeriodicCleanerExpectationManager handles this automatically via periodic cleanup + + // 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); + var set = + expectationManager.checkAndSetExpectation( + primary, context, timeoutRef.get(), deploymentReadyExpectation()); + if (set) { + return UpdateControl.noUpdate(); + } + } else { + // Checks the expectation and removes it once it is fulfilled. + // In your logic you might add a next expectation based on your workflow. + // Expectations have a name, so you can easily distinguish multiple expectations. + 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 patchStatusWithMessage(primary, DEPLOYMENT_READY); + } else if (res.isTimedOut()) { + // you might add some other timeout handling here + return patchStatusWithMessage(primary, DEPLOYMENT_TIMEOUT); + } + } + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + // Initialize expectationManager with primary cache from the context + // Use a short period (1 second) for faster testing + var primaryCache = context.getPrimaryCache(); + this.expectationManager = + new PeriodicCleanerExpectationManager<>(Duration.ofSeconds(1), primaryCache); + + return List.of( + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + Deployment.class, PeriodicCleanerExpectationCustomResource.class) + .build(), + context)); + } + + private static void createDeployment( + PeriodicCleanerExpectationCustomResource primary, + Context context) { + var deployment = + 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(); + deployment.addOwnerReference(primary); + context.getClient().resource(deployment).serverSideApply(); + } + + private static Expectation + deploymentReadyExpectation() { + return Expectation.createExpectation( + DEPLOYMENT_READY_EXPECTATION_NAME, + (primary, context) -> + context + .getSecondaryResource(Deployment.class) + .map( + ad -> + ad.getStatus() != null + && ad.getStatus().getReadyReplicas() != null + && ad.getStatus().getReadyReplicas() == 3) + .orElse(false)); + } + + private static UpdateControl patchStatusWithMessage( + PeriodicCleanerExpectationCustomResource primary, String message) { + primary.setStatus(new PeriodicCleanerExpectationCustomResourceStatus()); + primary.getStatus().setMessage(message); + return UpdateControl.patchStatus(primary); + } +}