diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java index a95847905f..442e398d83 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -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; @@ -43,6 +45,7 @@ public class LocallyRunOperatorExtension extends AbstractOperatorExtension { private final List localPortForwards; private final List> additionalCustomResourceDefinitions; private final Map registeredControllers; + private final Map crdMappings; private LocallyRunOperatorExtension( List reconcilers, @@ -56,7 +59,8 @@ private LocallyRunOperatorExtension( KubernetesClient kubernetesClient, Consumer configurationServiceOverrider, Function namespaceNameSupplier, - Function perClassNamespaceNameSupplier) { + Function perClassNamespaceNameSupplier, + Map crdMappings) { super( infrastructure, infrastructureTimeout, @@ -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; } /** @@ -83,6 +92,52 @@ public static Builder builder() { return new Builder(); } + public static void applyCrd(Class 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 reconcilers() { return reconcilers.stream().map(reconcilerSpec -> reconcilerSpec.reconciler); } @@ -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); } @@ -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); } @@ -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 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 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()); } } @@ -218,6 +276,7 @@ public static class Builder extends AbstractBuilder { private final List reconcilers; private final List portForwards; private final List> additionalCustomResourceDefinitions; + private final Map crdMappings; private KubernetesClient kubernetesClient; protected Builder() { @@ -225,6 +284,7 @@ protected Builder() { this.reconcilers = new ArrayList<>(); this.portForwards = new ArrayList<>(); this.additionalCustomResourceDefinitions = new ArrayList<>(); + this.crdMappings = new HashMap<>(); } public Builder withReconciler( @@ -279,6 +339,16 @@ public Builder withAdditionalCustomResourceDefinition( return this; } + public Builder withCRDMapping(Class 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, @@ -290,7 +360,8 @@ public LocallyRunOperatorExtension build() { waitForNamespaceDeletion, oneNamespacePerClass, kubernetesClient, - configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier); + configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier, + crdMappings); } } diff --git a/operator-framework/src/test/crd/test.crd b/operator-framework/src/test/crd/test.crd new file mode 100644 index 0000000000..f0891454fe --- /dev/null +++ b/operator-framework/src/test/crd/test.crd @@ -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 \ No newline at end of file diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java new file mode 100644 index 0000000000..a722f34b92 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java @@ -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 implements Namespaced { + } + + @ControllerConfiguration + private static class TestReconciler implements Reconciler { + @Override + public UpdateControl reconcile(TestCR resource, Context context) + throws Exception { + return null; + } + } +}