Skip to content

Commit d02b23e

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 <claprun@redhat.com>
1 parent 71e00ed commit d02b23e

File tree

3 files changed

+165
-32
lines changed

3 files changed

+165
-32
lines changed

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

+89-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,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

@@ -153,8 +208,8 @@ protected void before(ExtensionContext context) {
153208
}
154209

155210
// 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());
211+
if (CustomResource.class.isAssignableFrom(resourceClass)) {
212+
applyCrd(resourceClass);
158213
}
159214

160215
var registeredController = this.operator.register(ref.reconciler, oconfig.build());
@@ -165,31 +220,24 @@ protected void before(ExtensionContext context) {
165220
this.operator.start();
166221
}
167222

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

@@ -218,13 +266,15 @@ public static class Builder extends AbstractBuilder<Builder> {
218266
private final List<ReconcilerSpec> reconcilers;
219267
private final List<PortForwardSpec> portForwards;
220268
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
269+
private final Map<Class<? extends CustomResource>, String> crdMappings;
221270
private KubernetesClient kubernetesClient;
222271

223272
protected Builder() {
224273
super();
225274
this.reconcilers = new ArrayList<>();
226275
this.portForwards = new ArrayList<>();
227276
this.additionalCustomResourceDefinitions = new ArrayList<>();
277+
this.crdMappings = new HashMap<>();
228278
}
229279

230280
public Builder withReconciler(
@@ -279,6 +329,12 @@ public Builder withAdditionalCustomResourceDefinition(
279329
return this;
280330
}
281331

332+
public Builder withCRDMapping(Class<? extends CustomResource> customResourceClass,
333+
String path) {
334+
crdMappings.put(customResourceClass, path);
335+
return this;
336+
}
337+
282338
public LocallyRunOperatorExtension build() {
283339
return new LocallyRunOperatorExtension(
284340
reconcilers,
@@ -290,7 +346,8 @@ public LocallyRunOperatorExtension build() {
290346
waitForNamespaceDeletion,
291347
oneNamespacePerClass,
292348
kubernetesClient,
293-
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier);
349+
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier,
350+
crdMappings);
294351
}
295352
}
296353

+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 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(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)