diff --git a/docsy/content/en/docs/workflows/_index.md b/docsy/content/en/docs/workflows/_index.md index fa230665ba..a48eabb5ec 100644 --- a/docsy/content/en/docs/workflows/_index.md +++ b/docsy/content/en/docs/workflows/_index.md @@ -42,6 +42,12 @@ reconciliation process. platform (e.g. OpenShift vs plain Kubernetes) and/or change its behavior based on the availability of optional resources / features (e.g. CertManager, a specific Ingress controller, etc.). + A generic activation condition is provided out of the box, called + [CRDPresentActivationCondition](https://github.com/operator-framework/java-operator-sdk/blob/ba5e33527bf9e3ea0bd33025ccb35e677f9d44b4/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java) + that will prevent the associated dependent resource from being activated if the Custom Resource Definition associated + with the dependent's resource type is not present on the cluster. + See related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/ba5e33527bf9e3ea0bd33025ccb35e677f9d44b4/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDPresentActivationConditionIT.java). + Activation condition is semi-experimental at the moment, and it has its limitations. For example event sources cannot be shared between multiple managed dependent resources which use activation condition. The intention is to further improve and explore the possibilities with this approach. diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java new file mode 100644 index 0000000000..b3792bb9c7 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java @@ -0,0 +1,128 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +/** + * A generic CRD checking activation condition. Makes sure that the CRD is not checked unnecessarily + * even used in multiple condition. By default, it checks CRD at most 10 times with a delay at least + * 10 seconds. To fully customize CRD check trigger behavior you can extend this class and override + * the {@link CRDPresentActivationCondition#shouldCheckStateNow(CRDCheckState)} method. + **/ +public class CRDPresentActivationCondition + implements Condition { + + public static final int DEFAULT_CRD_CHECK_LIMIT = 10; + public static final Duration DEFAULT_CRD_CHECK_INTERVAL = Duration.ofSeconds(10); + + private static final Map crdPresenceCache = new ConcurrentHashMap<>(); + + private final CRDPresentChecker crdPresentChecker; + private final int checkLimit; + private final Duration crdCheckInterval; + + public CRDPresentActivationCondition() { + this(DEFAULT_CRD_CHECK_LIMIT, DEFAULT_CRD_CHECK_INTERVAL); + } + + public CRDPresentActivationCondition(int checkLimit, Duration crdCheckInterval) { + this(new CRDPresentChecker(), checkLimit, crdCheckInterval); + } + + // for testing purposes only + CRDPresentActivationCondition(CRDPresentChecker crdPresentChecker, int checkLimit, + Duration crdCheckInterval) { + this.crdPresentChecker = crdPresentChecker; + this.checkLimit = checkLimit; + this.crdCheckInterval = crdCheckInterval; + } + + @Override + public boolean isMet(DependentResource dependentResource, + P primary, Context

context) { + + var resourceClass = dependentResource.resourceType(); + final var crdName = HasMetadata.getFullResourceName(resourceClass); + + var crdCheckState = crdPresenceCache.computeIfAbsent(crdName, + g -> new CRDCheckState()); + + synchronized (crdCheckState) { + if (shouldCheckStateNow(crdCheckState)) { + boolean isPresent = crdPresentChecker + .checkIfCRDPresent(crdName, context.getClient()); + crdCheckState.checkedNow(isPresent); + } + } + + if (crdCheckState.isCrdPresent() == null) { + throw new IllegalStateException("State should be already checked at this point."); + } + return crdCheckState.isCrdPresent(); + } + + /** + * Override this method to fine tune when the crd state should be refreshed; + */ + protected boolean shouldCheckStateNow(CRDCheckState crdCheckState) { + if (crdCheckState.isCrdPresent() == null) { + return true; + } + // assumption is that if CRD is present, it is not deleted anymore + if (crdCheckState.isCrdPresent()) { + return false; + } + if (crdCheckState.getCheckCount() >= checkLimit) { + return false; + } + if (crdCheckState.getLastChecked() == null) { + return true; + } + return LocalDateTime.now().isAfter(crdCheckState.getLastChecked().plus(crdCheckInterval)); + } + + public static class CRDCheckState { + private Boolean crdPresent; + private LocalDateTime lastChecked; + private int checkCount = 0; + + public void checkedNow(boolean crdPresent) { + this.crdPresent = crdPresent; + lastChecked = LocalDateTime.now(); + checkCount++; + } + + public Boolean isCrdPresent() { + return crdPresent; + } + + public LocalDateTime getLastChecked() { + return lastChecked; + } + + public int getCheckCount() { + return checkCount; + } + } + + public static class CRDPresentChecker { + boolean checkIfCRDPresent(String crdName, KubernetesClient client) { + return client.resources(CustomResourceDefinition.class) + .withName(crdName).get() != null; + } + } + + /** For testing purposes only */ + public static void clearState() { + crdPresenceCache.clear(); + } + +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationConditionTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationConditionTest.java new file mode 100644 index 0000000000..58990214a3 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationConditionTest.java @@ -0,0 +1,91 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SuppressWarnings({"unchecked", "rawtypes"}) +class CRDPresentActivationConditionTest { + + public static final int TEST_CHECK_INTERVAL = 50; + public static final int TEST_CHECK_INTERVAL_WITH_SLACK = TEST_CHECK_INTERVAL + 10; + private final CRDPresentActivationCondition.CRDPresentChecker checkerMock = + mock(CRDPresentActivationCondition.CRDPresentChecker.class); + private final CRDPresentActivationCondition condition = + new CRDPresentActivationCondition(checkerMock, 2, + Duration.ofMillis(TEST_CHECK_INTERVAL)); + private final DependentResource dr = + mock(DependentResource.class); + private final Context context = mock(Context.class); + + + @BeforeEach + void setup() { + CRDPresentActivationCondition.clearState(); + when(checkerMock.checkIfCRDPresent(any(), any())).thenReturn(false); + when(dr.resourceType()).thenReturn(TestCustomResource.class); + } + + + @Test + void checkCRDIfNotCheckedBefore() { + when(checkerMock.checkIfCRDPresent(any(),any())).thenReturn(true); + + assertThat(condition.isMet(dr,null,context)).isTrue(); + verify(checkerMock, times(1)).checkIfCRDPresent(any(),any()); + } + + @Test + void instantMetCallSkipsApiCall() { + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + } + + @Test + void intervalExpiredAPICheckedAgain() throws InterruptedException { + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + + condition.isMet(dr, null, context); + verify(checkerMock, times(2)).checkIfCRDPresent(any(), any()); + } + + @Test + void crdIsNotCheckedAnymoreIfIfOnceFound() throws InterruptedException { + when(checkerMock.checkIfCRDPresent(any(),any())).thenReturn(true); + + condition.isMet(dr,null,context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(),any()); + + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + + condition.isMet(dr,null,context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(),any()); + } + + @Test + void crdNotCheckedAnymoreIfCountExpires() throws InterruptedException { + condition.isMet(dr, null, context); + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + condition.isMet(dr, null, context); + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + condition.isMet(dr, null, context); + + verify(checkerMock, times(2)).checkIfCRDPresent(any(), any()); + } + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDPresentActivationConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDPresentActivationConditionIT.java new file mode 100644 index 0000000000..67b2920836 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDPresentActivationConditionIT.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.sample.crdpresentactivation.CRDPresentActivationCustomResource; +import io.javaoperatorsdk.operator.sample.crdpresentactivation.CRDPresentActivationDependentCustomResource; +import io.javaoperatorsdk.operator.sample.crdpresentactivation.CRDPresentActivationReconciler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class CRDPresentActivationConditionIT { + + public static final String TEST_1 = "test1"; + public static final String CRD_NAME = + "crdpresentactivationdependentcustomresources.sample.javaoperatorsdk"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new CRDPresentActivationReconciler()) + .build(); + + + @Test + void resourceCreatedOnlyIfCRDPresent() { + // deleted so test can be repeated + extension.getKubernetesClient().resources(CustomResourceDefinition.class) + .withName(CRD_NAME).delete(); + + var resource = extension.create(testResource()); + + await().pollDelay(Duration.ofMillis(300)).untilAsserted(() -> { + var crd = extension.getKubernetesClient().resources(CustomResourceDefinition.class) + .withName(CRD_NAME).get(); + assertThat(crd).isNull(); + + var dr = extension.get(CRDPresentActivationDependentCustomResource.class, TEST_1); + assertThat(dr).isNull(); + }); + + LocallyRunOperatorExtension.applyCrd(CRDPresentActivationDependentCustomResource.class, + extension.getKubernetesClient()); + + resource.getMetadata().setAnnotations(Map.of("sample", "value")); + extension.replace(resource); + + await().pollDelay(Duration.ofMillis(300)).untilAsserted(() -> { + var cm = extension.get(CRDPresentActivationDependentCustomResource.class, TEST_1); + assertThat(cm).isNull(); + }); + + } + + CRDPresentActivationCustomResource testResource() { + var res = new CRDPresentActivationCustomResource(); + res.setMetadata(new ObjectMetaBuilder() + .withName(TEST_1) + .build()); + return res; + } + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationCustomResource.java new file mode 100644 index 0000000000..af2aea00ab --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationCustomResource.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.sample.crdpresentactivation; + +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("crdp") +public class CRDPresentActivationCustomResource + extends CustomResource + implements Namespaced { + + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationDependent.java new file mode 100644 index 0000000000..6e86e2a475 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationDependent.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.sample.crdpresentactivation; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class CRDPresentActivationDependent + extends + CRUDNoGCKubernetesDependentResource { + + public CRDPresentActivationDependent() { + super(CRDPresentActivationDependentCustomResource.class); + } + + @Override + protected CRDPresentActivationDependentCustomResource desired( + CRDPresentActivationCustomResource primary, + Context context) { + var res = new CRDPresentActivationDependentCustomResource(); + res.setMetadata(new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationDependentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationDependentCustomResource.java new file mode 100644 index 0000000000..899cea0e69 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationDependentCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.sample.crdpresentactivation; + +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("addp") +public class CRDPresentActivationDependentCustomResource extends CustomResource + implements Namespaced { + + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationReconciler.java new file mode 100644 index 0000000000..ef96f3b7eb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/crdpresentactivation/CRDPresentActivationReconciler.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.sample.crdpresentactivation; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.CRDPresentActivationCondition; + +@Workflow(dependents = { + @Dependent(type = CRDPresentActivationDependent.class, + activationCondition = CRDPresentActivationCondition.class), +}) +// to trigger reconciliation with metadata change +@ControllerConfiguration(generationAwareEventProcessing = false) +public class CRDPresentActivationReconciler + implements Reconciler, + Cleaner { + + @Override + public UpdateControl reconcile( + CRDPresentActivationCustomResource resource, + Context context) { + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup(CRDPresentActivationCustomResource resource, + Context context) { + return DeleteControl.defaultDelete(); + } +}