Skip to content

Commit 07e744b

Browse files
authored
feat: allow manually specifying CRDs in test extension (#2569)
* feat: allow manually specifying CRDs in test extension This is useful when using a contract-first approach where the Java classes are generated from the CRD instead of the reverse. Fixes #2561 Signed-off-by: Chris Laprun <claprun@redhat.com> * refactor: register CRDs via resource name instead of class Signed-off-by: Chris Laprun <claprun@redhat.com> --------- Signed-off-by: Chris Laprun <claprun@redhat.com>
1 parent 12810d2 commit 07e744b

File tree

3 files changed

+177
-30
lines changed

3 files changed

+177
-30
lines changed

operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.javaoperatorsdk.operator.junit;
22

33
import java.io.ByteArrayInputStream;
4+
import java.io.FileInputStream;
5+
import java.io.IOException;
46
import java.io.InputStream;
57
import java.nio.charset.StandardCharsets;
68
import java.time.Duration;
@@ -43,6 +45,7 @@ public class LocallyRunOperatorExtension extends AbstractOperatorExtension {
4345
private final List<LocalPortForward> localPortForwards;
4446
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
4547
private final Map<Reconciler, RegisteredController> registeredControllers;
48+
private final Map<String, String> crdMappings;
4649

4750
private LocallyRunOperatorExtension(
4851
List<ReconcilerSpec> reconcilers,
@@ -56,7 +59,8 @@ private LocallyRunOperatorExtension(
5659
KubernetesClient kubernetesClient,
5760
Consumer<ConfigurationServiceOverrider> configurationServiceOverrider,
5861
Function<ExtensionContext, String> namespaceNameSupplier,
59-
Function<ExtensionContext, String> perClassNamespaceNameSupplier) {
62+
Function<ExtensionContext, String> perClassNamespaceNameSupplier,
63+
Map<String, String> crdMappings) {
6064
super(
6165
infrastructure,
6266
infrastructureTimeout,
@@ -70,8 +74,13 @@ private LocallyRunOperatorExtension(
7074
this.portForwards = portForwards;
7175
this.localPortForwards = new ArrayList<>(portForwards.size());
7276
this.additionalCustomResourceDefinitions = additionalCustomResourceDefinitions;
73-
this.operator = new Operator(getKubernetesClient(), configurationServiceOverrider);
77+
configurationServiceOverrider = configurationServiceOverrider != null
78+
? configurationServiceOverrider
79+
.andThen(overrider -> overrider.withKubernetesClient(kubernetesClient))
80+
: overrider -> overrider.withKubernetesClient(kubernetesClient);
81+
this.operator = new Operator(configurationServiceOverrider);
7482
this.registeredControllers = new HashMap<>();
83+
this.crdMappings = crdMappings;
7584
}
7685

7786
/**
@@ -83,6 +92,52 @@ public static Builder builder() {
8392
return new Builder();
8493
}
8594

95+
public static void applyCrd(Class<? extends HasMetadata> resourceClass, KubernetesClient client) {
96+
applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client);
97+
}
98+
99+
/**
100+
* Applies the CRD associated with the specified resource name to the cluster. Note that the CRD
101+
* is assumed to have been generated in this case from the Java classes and is therefore expected
102+
* to be found in the standard location with the default name for such CRDs and assumes a v1
103+
* version of the CRD spec is used. This means that, provided a given {@code resourceTypeName},
104+
* the associated CRD is expected to be found at {@code META-INF/fabric8/resourceTypeName-v1.yml}
105+
* in the project's classpath.
106+
*
107+
* @param resourceTypeName the standard resource name for CRDs i.e. {@code plural.group}
108+
* @param client the kubernetes client to use to connect to the cluster
109+
*/
110+
public static void applyCrd(String resourceTypeName, KubernetesClient client) {
111+
String path = "/META-INF/fabric8/" + resourceTypeName + "-v1.yml";
112+
try (InputStream is = LocallyRunOperatorExtension.class.getResourceAsStream(path)) {
113+
applyCrd(is, path, client);
114+
} catch (IllegalStateException e) {
115+
// rethrow directly
116+
throw e;
117+
} catch (IOException e) {
118+
throw new IllegalStateException("Cannot apply CRD yaml: " + path, e);
119+
}
120+
}
121+
122+
private static void applyCrd(InputStream is, String path, KubernetesClient client) {
123+
try {
124+
if (is == null) {
125+
throw new IllegalStateException("Cannot find CRD at " + path);
126+
}
127+
var crdString = new String(is.readAllBytes(), StandardCharsets.UTF_8);
128+
LOGGER.debug("Applying CRD: {}", crdString);
129+
final var crd = client.load(new ByteArrayInputStream(crdString.getBytes()));
130+
crd.serverSideApply();
131+
Thread.sleep(CRD_READY_WAIT); // readiness is not applicable for CRD, just wait a little
132+
LOGGER.debug("Applied CRD with path: {}", path);
133+
} catch (InterruptedException ex) {
134+
LOGGER.error("Interrupted.", ex);
135+
Thread.currentThread().interrupt();
136+
} catch (Exception ex) {
137+
throw new IllegalStateException("Cannot apply CRD yaml: " + path, ex);
138+
}
139+
}
140+
86141
private Stream<Reconciler> reconcilers() {
87142
return reconcilers.stream().map(reconcilerSpec -> reconcilerSpec.reconciler);
88143
}
@@ -134,14 +189,14 @@ protected void before(ExtensionContext context) {
134189
.withName(podName).portForward(ref.getPort(), ref.getLocalPort()));
135190
}
136191

137-
additionalCustomResourceDefinitions
138-
.forEach(cr -> applyCrd(ReconcilerUtils.getResourceTypeName(cr)));
192+
additionalCustomResourceDefinitions.forEach(this::applyCrd);
139193

140194
for (var ref : reconcilers) {
141195
final var config = operator.getConfigurationService().getConfigurationFor(ref.reconciler);
142196
final var oconfig = override(config);
143197

144-
if (Namespaced.class.isAssignableFrom(config.getResourceClass())) {
198+
final var resourceClass = config.getResourceClass();
199+
if (Namespaced.class.isAssignableFrom(resourceClass)) {
145200
oconfig.settingNamespace(namespace);
146201
}
147202

@@ -152,11 +207,17 @@ protected void before(ExtensionContext context) {
152207
ref.controllerConfigurationOverrider.accept(oconfig);
153208
}
154209

210+
final var unapplied = new HashMap<>(crdMappings);
211+
final var resourceTypeName = ReconcilerUtils.getResourceTypeName(resourceClass);
155212
// only try to apply a CRD for the reconciler if it is associated to a CR
156-
if (CustomResource.class.isAssignableFrom(config.getResourceClass())) {
157-
applyCrd(config.getResourceTypeName());
213+
if (CustomResource.class.isAssignableFrom(resourceClass)) {
214+
applyCrd(resourceTypeName);
215+
unapplied.remove(resourceTypeName);
158216
}
159217

218+
// apply yet unapplied CRDs
219+
unapplied.keySet().forEach(this::applyCrd);
220+
160221
var registeredController = this.operator.register(ref.reconciler, oconfig.build());
161222
registeredControllers.put(ref.reconciler, registeredController);
162223
}
@@ -165,31 +226,28 @@ protected void before(ExtensionContext context) {
165226
this.operator.start();
166227
}
167228

168-
private void applyCrd(String resourceTypeName) {
169-
applyCrd(resourceTypeName, getKubernetesClient());
170-
}
171-
172-
public static void applyCrd(Class<? extends HasMetadata> resourceClass, KubernetesClient client) {
173-
applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client);
229+
/**
230+
* Applies the CRD associated with the specified custom resource, first checking if a CRD has been
231+
* manually specified using {@link Builder#withCRDMapping(Class, String)}, otherwise assuming that
232+
* its CRD should be found in the standard location as explained in
233+
* {@link LocallyRunOperatorExtension#applyCrd(String, KubernetesClient)}
234+
*
235+
* @param crClass the custom resource class for which we want to apply the CRD
236+
*/
237+
public void applyCrd(Class<? extends CustomResource> crClass) {
238+
applyCrd(ReconcilerUtils.getResourceTypeName(crClass));
174239
}
175240

176-
public static void applyCrd(String resourceTypeName, KubernetesClient client) {
177-
String path = "/META-INF/fabric8/" + resourceTypeName + "-v1.yml";
178-
try (InputStream is = LocallyRunOperatorExtension.class.getResourceAsStream(path)) {
179-
if (is == null) {
180-
throw new IllegalStateException("Cannot find CRD at " + path);
241+
public void applyCrd(String resourceTypeName) {
242+
final var path = crdMappings.get(resourceTypeName);
243+
if (path != null) {
244+
try (InputStream inputStream = new FileInputStream(path)) {
245+
applyCrd(inputStream, path, getKubernetesClient());
246+
} catch (IOException e) {
247+
throw new IllegalStateException("Cannot apply CRD yaml: " + path, e);
181248
}
182-
var crdString = new String(is.readAllBytes(), StandardCharsets.UTF_8);
183-
LOGGER.debug("Applying CRD: {}", crdString);
184-
final var crd = client.load(new ByteArrayInputStream(crdString.getBytes()));
185-
crd.serverSideApply();
186-
Thread.sleep(CRD_READY_WAIT); // readiness is not applicable for CRD, just wait a little
187-
LOGGER.debug("Applied CRD with path: {}", path);
188-
} catch (InterruptedException ex) {
189-
LOGGER.error("Interrupted.", ex);
190-
Thread.currentThread().interrupt();
191-
} catch (Exception ex) {
192-
throw new IllegalStateException("Cannot apply CRD yaml: " + path, ex);
249+
} else {
250+
applyCrd(resourceTypeName, getKubernetesClient());
193251
}
194252
}
195253

@@ -218,13 +276,15 @@ public static class Builder extends AbstractBuilder<Builder> {
218276
private final List<ReconcilerSpec> reconcilers;
219277
private final List<PortForwardSpec> portForwards;
220278
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
279+
private final Map<String, String> crdMappings;
221280
private KubernetesClient kubernetesClient;
222281

223282
protected Builder() {
224283
super();
225284
this.reconcilers = new ArrayList<>();
226285
this.portForwards = new ArrayList<>();
227286
this.additionalCustomResourceDefinitions = new ArrayList<>();
287+
this.crdMappings = new HashMap<>();
228288
}
229289

230290
public Builder withReconciler(
@@ -279,6 +339,16 @@ public Builder withAdditionalCustomResourceDefinition(
279339
return this;
280340
}
281341

342+
public Builder withCRDMapping(Class<? extends CustomResource> customResourceClass,
343+
String path) {
344+
return withCRDMapping(ReconcilerUtils.getResourceTypeName(customResourceClass), path);
345+
}
346+
347+
public Builder withCRDMapping(String resourceTypeName, String path) {
348+
crdMappings.put(resourceTypeName, path);
349+
return this;
350+
}
351+
282352
public LocallyRunOperatorExtension build() {
283353
return new LocallyRunOperatorExtension(
284354
reconcilers,
@@ -290,7 +360,8 @@ public LocallyRunOperatorExtension build() {
290360
waitForNamespaceDeletion,
291361
oneNamespacePerClass,
292362
kubernetesClient,
293-
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier);
363+
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier,
364+
crdMappings);
294365
}
295366
}
296367

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
apiVersion: apiextensions.k8s.io/v1
2+
kind: CustomResourceDefinition
3+
metadata:
4+
name: tests.crd.example
5+
spec:
6+
group: crd.example
7+
names:
8+
kind: Test
9+
singular: test
10+
plural: tests
11+
scope: Namespaced
12+
versions:
13+
- name: v1
14+
schema:
15+
openAPIV3Schema:
16+
properties:
17+
type: "object"
18+
served: true
19+
storage: true
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.javaoperatorsdk.operator;
2+
3+
import java.time.Duration;
4+
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.RegisterExtension;
7+
8+
import io.fabric8.kubernetes.api.model.Namespaced;
9+
import io.fabric8.kubernetes.client.CustomResource;
10+
import io.fabric8.kubernetes.client.KubernetesClient;
11+
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
12+
import io.fabric8.kubernetes.model.annotation.Group;
13+
import io.fabric8.kubernetes.model.annotation.Kind;
14+
import io.fabric8.kubernetes.model.annotation.Version;
15+
import io.javaoperatorsdk.operator.api.reconciler.Context;
16+
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
17+
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
18+
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
19+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
import static org.awaitility.Awaitility.await;
23+
24+
public class CRDMappingInTestExtensionIT {
25+
private final KubernetesClient client = new KubernetesClientBuilder().build();
26+
27+
@RegisterExtension
28+
LocallyRunOperatorExtension operator =
29+
LocallyRunOperatorExtension.builder()
30+
.withReconciler(new TestReconciler())
31+
.withCRDMapping("tests.crd.example", "src/test/crd/test.crd")
32+
.build();
33+
34+
@Test
35+
void correctlyAppliesManuallySpecifiedCRD() {
36+
operator.applyCrd(TestCR.class);
37+
38+
final var crdClient = client.apiextensions().v1().customResourceDefinitions();
39+
await().pollDelay(Duration.ofMillis(150))
40+
.untilAsserted(() -> assertThat(crdClient.withName("tests.crd.example").get()).isNotNull());
41+
}
42+
43+
@Group("crd.example")
44+
@Version("v1")
45+
@Kind("Test")
46+
private static class TestCR extends CustomResource<Void, Void> implements Namespaced {
47+
}
48+
49+
@ControllerConfiguration
50+
private static class TestReconciler implements Reconciler<TestCR> {
51+
@Override
52+
public UpdateControl<TestCR> reconcile(TestCR resource, Context<TestCR> context)
53+
throws Exception {
54+
return null;
55+
}
56+
}
57+
}

0 commit comments

Comments
 (0)