Skip to content

Commit 95a8812

Browse files
committed
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 <[email protected]>
1 parent 71e00ed commit 95a8812

File tree

3 files changed

+164
-32
lines changed

3 files changed

+164
-32
lines changed

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

+88-32
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<Class<? extends CustomResource>, 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<Class<? extends CustomResource>, String> crdMappings) {
6064
super(
6165
infrastructure,
6266
infrastructureTimeout,
@@ -70,8 +74,12 @@ 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.andThen(overrider -> overrider.withKubernetesClient(kubernetesClient)) :
79+
overrider -> overrider.withKubernetesClient(kubernetesClient);
80+
this.operator = new Operator(configurationServiceOverrider);
7481
this.registeredControllers = new HashMap<>();
82+
this.crdMappings = crdMappings;
7583
}
7684

7785
/**
@@ -83,6 +91,52 @@ public static Builder builder() {
8391
return new Builder();
8492
}
8593

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

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

140193
for (var ref : reconcilers) {
141194
final var config = operator.getConfigurationService().getConfigurationFor(ref.reconciler);
142195
final var oconfig = override(config);
143196

144-
if (Namespaced.class.isAssignableFrom(config.getResourceClass())) {
197+
final var resourceClass = config.getResourceClass();
198+
if (Namespaced.class.isAssignableFrom(resourceClass)) {
145199
oconfig.settingNamespace(namespace);
146200
}
147201

@@ -153,8 +207,8 @@ protected void before(ExtensionContext context) {
153207
}
154208

155209
// 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());
210+
if (CustomResource.class.isAssignableFrom(resourceClass)) {
211+
applyCrd(resourceClass);
158212
}
159213

160214
var registeredController = this.operator.register(ref.reconciler, oconfig.build());
@@ -165,31 +219,24 @@ protected void before(ExtensionContext context) {
165219
this.operator.start();
166220
}
167221

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);
174-
}
175-
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);
222+
/**
223+
* Applies the CRD associated with the specified custom resource, first checking if a CRD has been
224+
* manually specified using {@link Builder#withCRDMapping(Class, String)}, otherwise assuming that
225+
* its CRD should be found in the standard location as explained in
226+
* {@link LocallyRunOperatorExtension#applyCrd(String, KubernetesClient)}
227+
*
228+
* @param crClass the custom resource class for which we want to apply the CRD
229+
*/
230+
public void applyCrd(Class<? extends CustomResource> crClass) {
231+
final var path = crdMappings.get(crClass);
232+
if (path != null) {
233+
try (InputStream inputStream = new FileInputStream(path)) {
234+
applyCrd(inputStream, path, getKubernetesClient());
235+
} catch (IOException e) {
236+
throw new IllegalStateException("Cannot apply CRD yaml: " + path, e);
181237
}
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);
238+
} else {
239+
applyCrd(crClass, getKubernetesClient());
193240
}
194241
}
195242

@@ -218,13 +265,15 @@ public static class Builder extends AbstractBuilder<Builder> {
218265
private final List<ReconcilerSpec> reconcilers;
219266
private final List<PortForwardSpec> portForwards;
220267
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
268+
private final Map<Class<? extends CustomResource>, String> crdMappings;
221269
private KubernetesClient kubernetesClient;
222270

223271
protected Builder() {
224272
super();
225273
this.reconcilers = new ArrayList<>();
226274
this.portForwards = new ArrayList<>();
227275
this.additionalCustomResourceDefinitions = new ArrayList<>();
276+
this.crdMappings = new HashMap<>();
228277
}
229278

230279
public Builder withReconciler(
@@ -279,6 +328,12 @@ public Builder withAdditionalCustomResourceDefinition(
279328
return this;
280329
}
281330

331+
public Builder withCRDMapping(Class<? extends CustomResource> customResourceClass,
332+
String path) {
333+
crdMappings.put(customResourceClass, path);
334+
return this;
335+
}
336+
282337
public LocallyRunOperatorExtension build() {
283338
return new LocallyRunOperatorExtension(
284339
reconcilers,
@@ -290,7 +345,8 @@ public LocallyRunOperatorExtension build() {
290345
waitForNamespaceDeletion,
291346
oneNamespacePerClass,
292347
kubernetesClient,
293-
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier);
348+
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier,
349+
crdMappings);
294350
}
295351
}
296352

+19
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
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 io.fabric8.kubernetes.model.annotation.Group;
6+
import io.fabric8.kubernetes.model.annotation.Kind;
7+
import io.fabric8.kubernetes.model.annotation.Version;
8+
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
9+
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.api.extension.RegisterExtension;
11+
12+
import io.fabric8.kubernetes.api.model.Namespaced;
13+
import io.fabric8.kubernetes.client.CustomResource;
14+
import io.fabric8.kubernetes.client.KubernetesClient;
15+
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
16+
import io.javaoperatorsdk.operator.api.reconciler.Context;
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(TestCR.class, "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)