Skip to content

Commit

Permalink
feat: allow manually specifying CRDs in test extension (#2569)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
metacosm authored Nov 8, 2024
1 parent 12810d2 commit 07e744b
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.javaoperatorsdk.operator.junit;

import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
Expand Down Expand Up @@ -43,6 +45,7 @@ public class LocallyRunOperatorExtension extends AbstractOperatorExtension {
private final List<LocalPortForward> localPortForwards;
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
private final Map<Reconciler, RegisteredController> registeredControllers;
private final Map<String, String> crdMappings;

private LocallyRunOperatorExtension(
List<ReconcilerSpec> reconcilers,
Expand All @@ -56,7 +59,8 @@ private LocallyRunOperatorExtension(
KubernetesClient kubernetesClient,
Consumer<ConfigurationServiceOverrider> configurationServiceOverrider,
Function<ExtensionContext, String> namespaceNameSupplier,
Function<ExtensionContext, String> perClassNamespaceNameSupplier) {
Function<ExtensionContext, String> perClassNamespaceNameSupplier,
Map<String, String> crdMappings) {
super(
infrastructure,
infrastructureTimeout,
Expand All @@ -70,8 +74,13 @@ private LocallyRunOperatorExtension(
this.portForwards = portForwards;
this.localPortForwards = new ArrayList<>(portForwards.size());
this.additionalCustomResourceDefinitions = additionalCustomResourceDefinitions;
this.operator = new Operator(getKubernetesClient(), configurationServiceOverrider);
configurationServiceOverrider = configurationServiceOverrider != null
? configurationServiceOverrider
.andThen(overrider -> overrider.withKubernetesClient(kubernetesClient))
: overrider -> overrider.withKubernetesClient(kubernetesClient);
this.operator = new Operator(configurationServiceOverrider);
this.registeredControllers = new HashMap<>();
this.crdMappings = crdMappings;
}

/**
Expand All @@ -83,6 +92,52 @@ public static Builder builder() {
return new Builder();
}

public static void applyCrd(Class<? extends HasMetadata> resourceClass, KubernetesClient client) {
applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client);
}

/**
* Applies the CRD associated with the specified resource name to the cluster. Note that the CRD
* is assumed to have been generated in this case from the Java classes and is therefore expected
* to be found in the standard location with the default name for such CRDs and assumes a v1
* version of the CRD spec is used. This means that, provided a given {@code resourceTypeName},
* the associated CRD is expected to be found at {@code META-INF/fabric8/resourceTypeName-v1.yml}
* in the project's classpath.
*
* @param resourceTypeName the standard resource name for CRDs i.e. {@code plural.group}
* @param client the kubernetes client to use to connect to the cluster
*/
public static void applyCrd(String resourceTypeName, KubernetesClient client) {
String path = "/META-INF/fabric8/" + resourceTypeName + "-v1.yml";
try (InputStream is = LocallyRunOperatorExtension.class.getResourceAsStream(path)) {
applyCrd(is, path, client);
} catch (IllegalStateException e) {
// rethrow directly
throw e;
} catch (IOException e) {
throw new IllegalStateException("Cannot apply CRD yaml: " + path, e);
}
}

private static void applyCrd(InputStream is, String path, KubernetesClient client) {
try {
if (is == null) {
throw new IllegalStateException("Cannot find CRD at " + path);
}
var crdString = new String(is.readAllBytes(), StandardCharsets.UTF_8);
LOGGER.debug("Applying CRD: {}", crdString);
final var crd = client.load(new ByteArrayInputStream(crdString.getBytes()));
crd.serverSideApply();
Thread.sleep(CRD_READY_WAIT); // readiness is not applicable for CRD, just wait a little
LOGGER.debug("Applied CRD with path: {}", path);
} catch (InterruptedException ex) {
LOGGER.error("Interrupted.", ex);
Thread.currentThread().interrupt();
} catch (Exception ex) {
throw new IllegalStateException("Cannot apply CRD yaml: " + path, ex);
}
}

private Stream<Reconciler> reconcilers() {
return reconcilers.stream().map(reconcilerSpec -> reconcilerSpec.reconciler);
}
Expand Down Expand Up @@ -134,14 +189,14 @@ protected void before(ExtensionContext context) {
.withName(podName).portForward(ref.getPort(), ref.getLocalPort()));
}

additionalCustomResourceDefinitions
.forEach(cr -> applyCrd(ReconcilerUtils.getResourceTypeName(cr)));
additionalCustomResourceDefinitions.forEach(this::applyCrd);

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

if (Namespaced.class.isAssignableFrom(config.getResourceClass())) {
final var resourceClass = config.getResourceClass();
if (Namespaced.class.isAssignableFrom(resourceClass)) {
oconfig.settingNamespace(namespace);
}

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

final var unapplied = new HashMap<>(crdMappings);
final var resourceTypeName = ReconcilerUtils.getResourceTypeName(resourceClass);
// only try to apply a CRD for the reconciler if it is associated to a CR
if (CustomResource.class.isAssignableFrom(config.getResourceClass())) {
applyCrd(config.getResourceTypeName());
if (CustomResource.class.isAssignableFrom(resourceClass)) {
applyCrd(resourceTypeName);
unapplied.remove(resourceTypeName);
}

// apply yet unapplied CRDs
unapplied.keySet().forEach(this::applyCrd);

var registeredController = this.operator.register(ref.reconciler, oconfig.build());
registeredControllers.put(ref.reconciler, registeredController);
}
Expand All @@ -165,31 +226,28 @@ protected void before(ExtensionContext context) {
this.operator.start();
}

private void applyCrd(String resourceTypeName) {
applyCrd(resourceTypeName, getKubernetesClient());
}

public static void applyCrd(Class<? extends HasMetadata> resourceClass, KubernetesClient client) {
applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client);
/**
* Applies the CRD associated with the specified custom resource, first checking if a CRD has been
* manually specified using {@link Builder#withCRDMapping(Class, String)}, otherwise assuming that
* its CRD should be found in the standard location as explained in
* {@link LocallyRunOperatorExtension#applyCrd(String, KubernetesClient)}
*
* @param crClass the custom resource class for which we want to apply the CRD
*/
public void applyCrd(Class<? extends CustomResource> crClass) {
applyCrd(ReconcilerUtils.getResourceTypeName(crClass));
}

public static void applyCrd(String resourceTypeName, KubernetesClient client) {
String path = "/META-INF/fabric8/" + resourceTypeName + "-v1.yml";
try (InputStream is = LocallyRunOperatorExtension.class.getResourceAsStream(path)) {
if (is == null) {
throw new IllegalStateException("Cannot find CRD at " + path);
public void applyCrd(String resourceTypeName) {
final var path = crdMappings.get(resourceTypeName);
if (path != null) {
try (InputStream inputStream = new FileInputStream(path)) {
applyCrd(inputStream, path, getKubernetesClient());
} catch (IOException e) {
throw new IllegalStateException("Cannot apply CRD yaml: " + path, e);
}
var crdString = new String(is.readAllBytes(), StandardCharsets.UTF_8);
LOGGER.debug("Applying CRD: {}", crdString);
final var crd = client.load(new ByteArrayInputStream(crdString.getBytes()));
crd.serverSideApply();
Thread.sleep(CRD_READY_WAIT); // readiness is not applicable for CRD, just wait a little
LOGGER.debug("Applied CRD with path: {}", path);
} catch (InterruptedException ex) {
LOGGER.error("Interrupted.", ex);
Thread.currentThread().interrupt();
} catch (Exception ex) {
throw new IllegalStateException("Cannot apply CRD yaml: " + path, ex);
} else {
applyCrd(resourceTypeName, getKubernetesClient());
}
}

Expand Down Expand Up @@ -218,13 +276,15 @@ public static class Builder extends AbstractBuilder<Builder> {
private final List<ReconcilerSpec> reconcilers;
private final List<PortForwardSpec> portForwards;
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
private final Map<String, String> crdMappings;
private KubernetesClient kubernetesClient;

protected Builder() {
super();
this.reconcilers = new ArrayList<>();
this.portForwards = new ArrayList<>();
this.additionalCustomResourceDefinitions = new ArrayList<>();
this.crdMappings = new HashMap<>();
}

public Builder withReconciler(
Expand Down Expand Up @@ -279,6 +339,16 @@ public Builder withAdditionalCustomResourceDefinition(
return this;
}

public Builder withCRDMapping(Class<? extends CustomResource> customResourceClass,
String path) {
return withCRDMapping(ReconcilerUtils.getResourceTypeName(customResourceClass), path);
}

public Builder withCRDMapping(String resourceTypeName, String path) {
crdMappings.put(resourceTypeName, path);
return this;
}

public LocallyRunOperatorExtension build() {
return new LocallyRunOperatorExtension(
reconcilers,
Expand All @@ -290,7 +360,8 @@ public LocallyRunOperatorExtension build() {
waitForNamespaceDeletion,
oneNamespacePerClass,
kubernetesClient,
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier);
configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier,
crdMappings);
}
}

Expand Down
19 changes: 19 additions & 0 deletions operator-framework/src/test/crd/test.crd
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: tests.crd.example
spec:
group: crd.example
names:
kind: Test
singular: test
plural: tests
scope: Namespaced
versions:
- name: v1
schema:
openAPIV3Schema:
properties:
type: "object"
served: true
storage: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.javaoperatorsdk.operator;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.fabric8.kubernetes.api.model.Namespaced;
import io.fabric8.kubernetes.client.CustomResource;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
import io.fabric8.kubernetes.model.annotation.Group;
import io.fabric8.kubernetes.model.annotation.Kind;
import io.fabric8.kubernetes.model.annotation.Version;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

public class CRDMappingInTestExtensionIT {
private final KubernetesClient client = new KubernetesClientBuilder().build();

@RegisterExtension
LocallyRunOperatorExtension operator =
LocallyRunOperatorExtension.builder()
.withReconciler(new TestReconciler())
.withCRDMapping("tests.crd.example", "src/test/crd/test.crd")
.build();

@Test
void correctlyAppliesManuallySpecifiedCRD() {
operator.applyCrd(TestCR.class);

final var crdClient = client.apiextensions().v1().customResourceDefinitions();
await().pollDelay(Duration.ofMillis(150))
.untilAsserted(() -> assertThat(crdClient.withName("tests.crd.example").get()).isNotNull());
}

@Group("crd.example")
@Version("v1")
@Kind("Test")
private static class TestCR extends CustomResource<Void, Void> implements Namespaced {
}

@ControllerConfiguration
private static class TestReconciler implements Reconciler<TestCR> {
@Override
public UpdateControl<TestCR> reconcile(TestCR resource, Context<TestCR> context)
throws Exception {
return null;
}
}
}

0 comments on commit 07e744b

Please sign in to comment.