From d02b23e74c98d9090042fcb964ca17ba0de804cc Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Wed, 6 Nov 2024 18:12:41 +0100 Subject: [PATCH 1/2] 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 --- .../junit/LocallyRunOperatorExtension.java | 121 +++++++++++++----- operator-framework/src/test/crd/test.crd | 19 +++ .../operator/CRDMappingInTestExtensionIT.java | 57 +++++++++ 3 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 operator-framework/src/test/crd/test.crd create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java 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..393656e654 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, String> crdMappings; private LocallyRunOperatorExtension( List reconcilers, @@ -56,7 +59,8 @@ private LocallyRunOperatorExtension( KubernetesClient kubernetesClient, Consumer configurationServiceOverrider, Function namespaceNameSupplier, - Function perClassNamespaceNameSupplier) { + Function perClassNamespaceNameSupplier, + Map, String> 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); } @@ -153,8 +208,8 @@ protected void before(ExtensionContext context) { } // 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(resourceClass); } var registeredController = this.operator.register(ref.reconciler, oconfig.build()); @@ -165,31 +220,24 @@ 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); - } - - 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); + /** + * 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) { + final var path = crdMappings.get(crClass); + 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(crClass, getKubernetesClient()); } } @@ -218,6 +266,7 @@ public static class Builder extends AbstractBuilder { private final List reconcilers; private final List portForwards; private final List> additionalCustomResourceDefinitions; + private final Map, String> crdMappings; private KubernetesClient kubernetesClient; protected Builder() { @@ -225,6 +274,7 @@ protected Builder() { this.reconcilers = new ArrayList<>(); this.portForwards = new ArrayList<>(); this.additionalCustomResourceDefinitions = new ArrayList<>(); + this.crdMappings = new HashMap<>(); } public Builder withReconciler( @@ -279,6 +329,12 @@ public Builder withAdditionalCustomResourceDefinition( return this; } + public Builder withCRDMapping(Class customResourceClass, + String path) { + crdMappings.put(customResourceClass, path); + return this; + } + public LocallyRunOperatorExtension build() { return new LocallyRunOperatorExtension( reconcilers, @@ -290,7 +346,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..b65d831d56 --- /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(TestCR.class, "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; + } + } +} From 27f9b644460765079cf5f4cebccaab5985eeda81 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 7 Nov 2024 18:23:26 +0100 Subject: [PATCH 2/2] refactor: register CRDs via resource name instead of class Signed-off-by: Chris Laprun --- .../junit/LocallyRunOperatorExtension.java | 28 ++++++++++++++----- .../operator/CRDMappingInTestExtensionIT.java | 2 +- 2 files changed, 22 insertions(+), 8 deletions(-) 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 393656e654..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 @@ -45,7 +45,7 @@ public class LocallyRunOperatorExtension extends AbstractOperatorExtension { private final List localPortForwards; private final List> additionalCustomResourceDefinitions; private final Map registeredControllers; - private final Map, String> crdMappings; + private final Map crdMappings; private LocallyRunOperatorExtension( List reconcilers, @@ -60,7 +60,7 @@ private LocallyRunOperatorExtension( Consumer configurationServiceOverrider, Function namespaceNameSupplier, Function perClassNamespaceNameSupplier, - Map, String> crdMappings) { + Map crdMappings) { super( infrastructure, infrastructureTimeout, @@ -207,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(resourceClass)) { - applyCrd(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); } @@ -229,7 +235,11 @@ protected void before(ExtensionContext context) { * @param crClass the custom resource class for which we want to apply the CRD */ public void applyCrd(Class crClass) { - final var path = crdMappings.get(crClass); + applyCrd(ReconcilerUtils.getResourceTypeName(crClass)); + } + + public void applyCrd(String resourceTypeName) { + final var path = crdMappings.get(resourceTypeName); if (path != null) { try (InputStream inputStream = new FileInputStream(path)) { applyCrd(inputStream, path, getKubernetesClient()); @@ -237,7 +247,7 @@ public void applyCrd(Class crClass) { throw new IllegalStateException("Cannot apply CRD yaml: " + path, e); } } else { - applyCrd(crClass, getKubernetesClient()); + applyCrd(resourceTypeName, getKubernetesClient()); } } @@ -266,7 +276,7 @@ public static class Builder extends AbstractBuilder { private final List reconcilers; private final List portForwards; private final List> additionalCustomResourceDefinitions; - private final Map, String> crdMappings; + private final Map crdMappings; private KubernetesClient kubernetesClient; protected Builder() { @@ -331,7 +341,11 @@ public Builder withAdditionalCustomResourceDefinition( public Builder withCRDMapping(Class customResourceClass, String path) { - crdMappings.put(customResourceClass, path); + return withCRDMapping(ReconcilerUtils.getResourceTypeName(customResourceClass), path); + } + + public Builder withCRDMapping(String resourceTypeName, String path) { + crdMappings.put(resourceTypeName, path); return this; } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java index b65d831d56..a722f34b92 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java @@ -28,7 +28,7 @@ public class CRDMappingInTestExtensionIT { LocallyRunOperatorExtension operator = LocallyRunOperatorExtension.builder() .withReconciler(new TestReconciler()) - .withCRDMapping(TestCR.class, "src/test/crd/test.crd") + .withCRDMapping("tests.crd.example", "src/test/crd/test.crd") .build(); @Test