Skip to content

feat: general crd checking activation condition #2433

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docsy/content/en/docs/workflows/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<R extends HasMetadata, P extends HasMetadata>
implements Condition<R, P> {

public static final int DEFAULT_CRD_CHECK_LIMIT = 10;
public static final Duration DEFAULT_CRD_CHECK_INTERVAL = Duration.ofSeconds(10);

private static final Map<String, CRDCheckState> 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<R, P> dependentResource,
P primary, Context<P> 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();
}

}
Original file line number Diff line number Diff line change
@@ -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<TestCustomResource, TestCustomResource> 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());
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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<Void, Void>
implements Namespaced {


}
Original file line number Diff line number Diff line change
@@ -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<CRDPresentActivationDependentCustomResource, CRDPresentActivationCustomResource> {

public CRDPresentActivationDependent() {
super(CRDPresentActivationDependentCustomResource.class);
}

@Override
protected CRDPresentActivationDependentCustomResource desired(
CRDPresentActivationCustomResource primary,
Context<CRDPresentActivationCustomResource> context) {
var res = new CRDPresentActivationDependentCustomResource();
res.setMetadata(new ObjectMetaBuilder()
.withName(primary.getMetadata().getName())
.withNamespace(primary.getMetadata().getNamespace())
.build());
return res;
}
}
Original file line number Diff line number Diff line change
@@ -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<Void, Void>
implements Namespaced {


}
Original file line number Diff line number Diff line change
@@ -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<CRDPresentActivationCustomResource>,
Cleaner<CRDPresentActivationCustomResource> {

@Override
public UpdateControl<CRDPresentActivationCustomResource> reconcile(
CRDPresentActivationCustomResource resource,
Context<CRDPresentActivationCustomResource> context) {

return UpdateControl.noUpdate();
}

@Override
public DeleteControl cleanup(CRDPresentActivationCustomResource resource,
Context<CRDPresentActivationCustomResource> context) {
return DeleteControl.defaultDelete();
}
}
Loading