Skip to content

Commit c3698c0

Browse files
csvirimetacosm
andcommitted
feat: general crd checking activation condition (#2433)
Signed-off-by: Attila Mészáros <[email protected]> Signed-off-by: Chris Laprun <[email protected]> Co-authored-by: Chris Laprun <[email protected]>
1 parent 5569ad5 commit c3698c0

File tree

8 files changed

+384
-0
lines changed

8 files changed

+384
-0
lines changed

Diff for: docsy/content/en/docs/workflows/_index.md

+6
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ reconciliation process.
4242
platform (e.g. OpenShift vs plain Kubernetes) and/or change its behavior based on the availability of optional
4343
resources / features (e.g. CertManager, a specific Ingress controller, etc.).
4444

45+
A generic activation condition is provided out of the box, called
46+
[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)
47+
that will prevent the associated dependent resource from being activated if the Custom Resource Definition associated
48+
with the dependent's resource type is not present on the cluster.
49+
See related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/ba5e33527bf9e3ea0bd33025ccb35e677f9d44b4/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDPresentActivationConditionIT.java).
50+
4551
Activation condition is semi-experimental at the moment, and it has its limitations.
4652
For example event sources cannot be shared between multiple managed dependent resources which use activation condition.
4753
The intention is to further improve and explore the possibilities with this approach.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package io.javaoperatorsdk.operator.processing.dependent.workflow;
2+
3+
import java.time.Duration;
4+
import java.time.LocalDateTime;
5+
import java.util.Map;
6+
import java.util.concurrent.ConcurrentHashMap;
7+
8+
import io.fabric8.kubernetes.api.model.HasMetadata;
9+
import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition;
10+
import io.fabric8.kubernetes.client.KubernetesClient;
11+
import io.javaoperatorsdk.operator.api.reconciler.Context;
12+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
13+
14+
/**
15+
* A generic CRD checking activation condition. Makes sure that the CRD is not checked unnecessarily
16+
* even used in multiple condition. By default, it checks CRD at most 10 times with a delay at least
17+
* 10 seconds. To fully customize CRD check trigger behavior you can extend this class and override
18+
* the {@link CRDPresentActivationCondition#shouldCheckStateNow(CRDCheckState)} method.
19+
**/
20+
public class CRDPresentActivationCondition<R extends HasMetadata, P extends HasMetadata>
21+
implements Condition<R, P> {
22+
23+
public static final int DEFAULT_CRD_CHECK_LIMIT = 10;
24+
public static final Duration DEFAULT_CRD_CHECK_INTERVAL = Duration.ofSeconds(10);
25+
26+
private static final Map<String, CRDCheckState> crdPresenceCache = new ConcurrentHashMap<>();
27+
28+
private final CRDPresentChecker crdPresentChecker;
29+
private final int checkLimit;
30+
private final Duration crdCheckInterval;
31+
32+
public CRDPresentActivationCondition() {
33+
this(DEFAULT_CRD_CHECK_LIMIT, DEFAULT_CRD_CHECK_INTERVAL);
34+
}
35+
36+
public CRDPresentActivationCondition(int checkLimit, Duration crdCheckInterval) {
37+
this(new CRDPresentChecker(), checkLimit, crdCheckInterval);
38+
}
39+
40+
// for testing purposes only
41+
CRDPresentActivationCondition(CRDPresentChecker crdPresentChecker, int checkLimit,
42+
Duration crdCheckInterval) {
43+
this.crdPresentChecker = crdPresentChecker;
44+
this.checkLimit = checkLimit;
45+
this.crdCheckInterval = crdCheckInterval;
46+
}
47+
48+
@Override
49+
public boolean isMet(DependentResource<R, P> dependentResource,
50+
P primary, Context<P> context) {
51+
52+
var resourceClass = dependentResource.resourceType();
53+
final var crdName = HasMetadata.getFullResourceName(resourceClass);
54+
55+
var crdCheckState = crdPresenceCache.computeIfAbsent(crdName,
56+
g -> new CRDCheckState());
57+
58+
synchronized (crdCheckState) {
59+
if (shouldCheckStateNow(crdCheckState)) {
60+
boolean isPresent = crdPresentChecker
61+
.checkIfCRDPresent(crdName, context.getClient());
62+
crdCheckState.checkedNow(isPresent);
63+
}
64+
}
65+
66+
if (crdCheckState.isCrdPresent() == null) {
67+
throw new IllegalStateException("State should be already checked at this point.");
68+
}
69+
return crdCheckState.isCrdPresent();
70+
}
71+
72+
/**
73+
* Override this method to fine tune when the crd state should be refreshed;
74+
*/
75+
protected boolean shouldCheckStateNow(CRDCheckState crdCheckState) {
76+
if (crdCheckState.isCrdPresent() == null) {
77+
return true;
78+
}
79+
// assumption is that if CRD is present, it is not deleted anymore
80+
if (crdCheckState.isCrdPresent()) {
81+
return false;
82+
}
83+
if (crdCheckState.getCheckCount() >= checkLimit) {
84+
return false;
85+
}
86+
if (crdCheckState.getLastChecked() == null) {
87+
return true;
88+
}
89+
return LocalDateTime.now().isAfter(crdCheckState.getLastChecked().plus(crdCheckInterval));
90+
}
91+
92+
public static class CRDCheckState {
93+
private Boolean crdPresent;
94+
private LocalDateTime lastChecked;
95+
private int checkCount = 0;
96+
97+
public void checkedNow(boolean crdPresent) {
98+
this.crdPresent = crdPresent;
99+
lastChecked = LocalDateTime.now();
100+
checkCount++;
101+
}
102+
103+
public Boolean isCrdPresent() {
104+
return crdPresent;
105+
}
106+
107+
public LocalDateTime getLastChecked() {
108+
return lastChecked;
109+
}
110+
111+
public int getCheckCount() {
112+
return checkCount;
113+
}
114+
}
115+
116+
public static class CRDPresentChecker {
117+
boolean checkIfCRDPresent(String crdName, KubernetesClient client) {
118+
return client.resources(CustomResourceDefinition.class)
119+
.withName(crdName).get() != null;
120+
}
121+
}
122+
123+
/** For testing purposes only */
124+
public static void clearState() {
125+
crdPresenceCache.clear();
126+
}
127+
128+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package io.javaoperatorsdk.operator.processing.dependent.workflow;
2+
3+
import java.time.Duration;
4+
5+
import org.junit.jupiter.api.BeforeEach;
6+
import org.junit.jupiter.api.Test;
7+
8+
import io.javaoperatorsdk.operator.api.reconciler.Context;
9+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
10+
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.mockito.ArgumentMatchers.any;
14+
import static org.mockito.Mockito.*;
15+
16+
@SuppressWarnings({"unchecked", "rawtypes"})
17+
class CRDPresentActivationConditionTest {
18+
19+
public static final int TEST_CHECK_INTERVAL = 50;
20+
public static final int TEST_CHECK_INTERVAL_WITH_SLACK = TEST_CHECK_INTERVAL + 10;
21+
private final CRDPresentActivationCondition.CRDPresentChecker checkerMock =
22+
mock(CRDPresentActivationCondition.CRDPresentChecker.class);
23+
private final CRDPresentActivationCondition condition =
24+
new CRDPresentActivationCondition(checkerMock, 2,
25+
Duration.ofMillis(TEST_CHECK_INTERVAL));
26+
private final DependentResource<TestCustomResource, TestCustomResource> dr =
27+
mock(DependentResource.class);
28+
private final Context context = mock(Context.class);
29+
30+
31+
@BeforeEach
32+
void setup() {
33+
CRDPresentActivationCondition.clearState();
34+
when(checkerMock.checkIfCRDPresent(any(), any())).thenReturn(false);
35+
when(dr.resourceType()).thenReturn(TestCustomResource.class);
36+
}
37+
38+
39+
@Test
40+
void checkCRDIfNotCheckedBefore() {
41+
when(checkerMock.checkIfCRDPresent(any(),any())).thenReturn(true);
42+
43+
assertThat(condition.isMet(dr,null,context)).isTrue();
44+
verify(checkerMock, times(1)).checkIfCRDPresent(any(),any());
45+
}
46+
47+
@Test
48+
void instantMetCallSkipsApiCall() {
49+
condition.isMet(dr, null, context);
50+
verify(checkerMock, times(1)).checkIfCRDPresent(any(), any());
51+
52+
condition.isMet(dr, null, context);
53+
verify(checkerMock, times(1)).checkIfCRDPresent(any(), any());
54+
}
55+
56+
@Test
57+
void intervalExpiredAPICheckedAgain() throws InterruptedException {
58+
condition.isMet(dr, null, context);
59+
verify(checkerMock, times(1)).checkIfCRDPresent(any(), any());
60+
61+
Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK);
62+
63+
condition.isMet(dr, null, context);
64+
verify(checkerMock, times(2)).checkIfCRDPresent(any(), any());
65+
}
66+
67+
@Test
68+
void crdIsNotCheckedAnymoreIfIfOnceFound() throws InterruptedException {
69+
when(checkerMock.checkIfCRDPresent(any(),any())).thenReturn(true);
70+
71+
condition.isMet(dr,null,context);
72+
verify(checkerMock, times(1)).checkIfCRDPresent(any(),any());
73+
74+
Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK);
75+
76+
condition.isMet(dr,null,context);
77+
verify(checkerMock, times(1)).checkIfCRDPresent(any(),any());
78+
}
79+
80+
@Test
81+
void crdNotCheckedAnymoreIfCountExpires() throws InterruptedException {
82+
condition.isMet(dr, null, context);
83+
Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK);
84+
condition.isMet(dr, null, context);
85+
Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK);
86+
condition.isMet(dr, null, context);
87+
88+
verify(checkerMock, times(2)).checkIfCRDPresent(any(), any());
89+
}
90+
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package io.javaoperatorsdk.operator;
2+
3+
import java.time.Duration;
4+
import java.util.Map;
5+
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.api.extension.RegisterExtension;
8+
9+
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
10+
import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition;
11+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
12+
import io.javaoperatorsdk.operator.sample.crdpresentactivation.CRDPresentActivationCustomResource;
13+
import io.javaoperatorsdk.operator.sample.crdpresentactivation.CRDPresentActivationDependentCustomResource;
14+
import io.javaoperatorsdk.operator.sample.crdpresentactivation.CRDPresentActivationReconciler;
15+
16+
import static org.assertj.core.api.Assertions.assertThat;
17+
import static org.awaitility.Awaitility.await;
18+
19+
public class CRDPresentActivationConditionIT {
20+
21+
public static final String TEST_1 = "test1";
22+
public static final String CRD_NAME =
23+
"crdpresentactivationdependentcustomresources.sample.javaoperatorsdk";
24+
25+
@RegisterExtension
26+
LocallyRunOperatorExtension extension =
27+
LocallyRunOperatorExtension.builder()
28+
.withReconciler(new CRDPresentActivationReconciler())
29+
.build();
30+
31+
32+
@Test
33+
void resourceCreatedOnlyIfCRDPresent() {
34+
// deleted so test can be repeated
35+
extension.getKubernetesClient().resources(CustomResourceDefinition.class)
36+
.withName(CRD_NAME).delete();
37+
38+
var resource = extension.create(testResource());
39+
40+
await().pollDelay(Duration.ofMillis(300)).untilAsserted(() -> {
41+
var crd = extension.getKubernetesClient().resources(CustomResourceDefinition.class)
42+
.withName(CRD_NAME).get();
43+
assertThat(crd).isNull();
44+
45+
var dr = extension.get(CRDPresentActivationDependentCustomResource.class, TEST_1);
46+
assertThat(dr).isNull();
47+
});
48+
49+
LocallyRunOperatorExtension.applyCrd(CRDPresentActivationDependentCustomResource.class,
50+
extension.getKubernetesClient());
51+
52+
resource.getMetadata().setAnnotations(Map.of("sample", "value"));
53+
extension.replace(resource);
54+
55+
await().pollDelay(Duration.ofMillis(300)).untilAsserted(() -> {
56+
var cm = extension.get(CRDPresentActivationDependentCustomResource.class, TEST_1);
57+
assertThat(cm).isNull();
58+
});
59+
60+
}
61+
62+
CRDPresentActivationCustomResource testResource() {
63+
var res = new CRDPresentActivationCustomResource();
64+
res.setMetadata(new ObjectMetaBuilder()
65+
.withName(TEST_1)
66+
.build());
67+
return res;
68+
}
69+
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package io.javaoperatorsdk.operator.sample.crdpresentactivation;
2+
3+
import io.fabric8.kubernetes.api.model.Namespaced;
4+
import io.fabric8.kubernetes.client.CustomResource;
5+
import io.fabric8.kubernetes.model.annotation.Group;
6+
import io.fabric8.kubernetes.model.annotation.ShortNames;
7+
import io.fabric8.kubernetes.model.annotation.Version;
8+
9+
@Group("sample.javaoperatorsdk")
10+
@Version("v1")
11+
@ShortNames("crdp")
12+
public class CRDPresentActivationCustomResource
13+
extends CustomResource<Void, Void>
14+
implements Namespaced {
15+
16+
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.javaoperatorsdk.operator.sample.crdpresentactivation;
2+
3+
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
4+
import io.javaoperatorsdk.operator.api.reconciler.Context;
5+
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource;
6+
7+
public class CRDPresentActivationDependent
8+
extends
9+
CRUDNoGCKubernetesDependentResource<CRDPresentActivationDependentCustomResource, CRDPresentActivationCustomResource> {
10+
11+
public CRDPresentActivationDependent() {
12+
super(CRDPresentActivationDependentCustomResource.class);
13+
}
14+
15+
@Override
16+
protected CRDPresentActivationDependentCustomResource desired(
17+
CRDPresentActivationCustomResource primary,
18+
Context<CRDPresentActivationCustomResource> context) {
19+
var res = new CRDPresentActivationDependentCustomResource();
20+
res.setMetadata(new ObjectMetaBuilder()
21+
.withName(primary.getMetadata().getName())
22+
.withNamespace(primary.getMetadata().getNamespace())
23+
.build());
24+
return res;
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.javaoperatorsdk.operator.sample.crdpresentactivation;
2+
3+
import io.fabric8.kubernetes.api.model.Namespaced;
4+
import io.fabric8.kubernetes.client.CustomResource;
5+
import io.fabric8.kubernetes.model.annotation.Group;
6+
import io.fabric8.kubernetes.model.annotation.ShortNames;
7+
import io.fabric8.kubernetes.model.annotation.Version;
8+
9+
@Group("sample.javaoperatorsdk")
10+
@Version("v1")
11+
@ShortNames("addp")
12+
public class CRDPresentActivationDependentCustomResource extends CustomResource<Void, Void>
13+
implements Namespaced {
14+
15+
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.javaoperatorsdk.operator.sample.crdpresentactivation;
2+
3+
import io.javaoperatorsdk.operator.api.reconciler.*;
4+
import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
5+
import io.javaoperatorsdk.operator.processing.dependent.workflow.CRDPresentActivationCondition;
6+
7+
@Workflow(dependents = {
8+
@Dependent(type = CRDPresentActivationDependent.class,
9+
activationCondition = CRDPresentActivationCondition.class),
10+
})
11+
// to trigger reconciliation with metadata change
12+
@ControllerConfiguration(generationAwareEventProcessing = false)
13+
public class CRDPresentActivationReconciler
14+
implements Reconciler<CRDPresentActivationCustomResource>,
15+
Cleaner<CRDPresentActivationCustomResource> {
16+
17+
@Override
18+
public UpdateControl<CRDPresentActivationCustomResource> reconcile(
19+
CRDPresentActivationCustomResource resource,
20+
Context<CRDPresentActivationCustomResource> context) {
21+
22+
return UpdateControl.noUpdate();
23+
}
24+
25+
@Override
26+
public DeleteControl cleanup(CRDPresentActivationCustomResource resource,
27+
Context<CRDPresentActivationCustomResource> context) {
28+
return DeleteControl.defaultDelete();
29+
}
30+
}

0 commit comments

Comments
 (0)