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 0a33293b53..5ea82026c3 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 @@ -67,6 +67,7 @@ public class LocallyRunOperatorExtension extends AbstractOperatorExtension { private final List portForwards; private final List localPortForwards; private final List> additionalCustomResourceDefinitions; + private final List additionalCustomResourceDefinitionInstances; private final Map registeredControllers; private final Map crdMappings; private final Consumer beforeStartHook; @@ -76,6 +77,7 @@ private LocallyRunOperatorExtension( List infrastructure, List portForwards, List> additionalCustomResourceDefinitions, + List additionalCustomResourceDefinitionInstances, Duration infrastructureTimeout, boolean preserveNamespaceOnError, boolean waitForNamespaceDeletion, @@ -101,6 +103,7 @@ private LocallyRunOperatorExtension( this.portForwards = portForwards; this.localPortForwards = new ArrayList<>(portForwards.size()); this.additionalCustomResourceDefinitions = additionalCustomResourceDefinitions; + this.additionalCustomResourceDefinitionInstances = additionalCustomResourceDefinitionInstances; this.beforeStartHook = beforeStartHook; configurationServiceOverrider = configurationServiceOverrider != null @@ -172,7 +175,7 @@ private static void applyCrd(String crdString, String path, KubernetesClient cli LOGGER.debug("Applying CRD: {}", crdString); final var crd = client.load(new ByteArrayInputStream(crdString.getBytes())); crd.serverSideApply(); - appliedCRDs.add(new AppliedCRD(crdString, path)); + appliedCRDs.add(new AppliedCRD.FileCRD(crdString, path)); 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) { @@ -195,6 +198,33 @@ public void applyCrd(Class crClass) { applyCrd(ReconcilerUtils.getResourceTypeName(crClass)); } + public void applyCrd(CustomResourceDefinition customResourceDefinition) { + try { + String resourceTypeName = customResourceDefinition.getMetadata().getName(); + final var pathAsString = crdMappings.get(resourceTypeName); + if (pathAsString != null) { + applyCrdFromMappings(pathAsString, resourceTypeName); + } else { + var resource = getKubernetesClient().resource(customResourceDefinition); + resource.serverSideApply(); + Thread.sleep(CRD_READY_WAIT); // readiness is not applicable for CRD, just wait a little + appliedCRDs.add(new AppliedCRD.InstanceCRD(customResourceDefinition)); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void applyCrdFromMappings(String pathAsString, String resourceTypeName) { + final var path = Path.of(pathAsString); + try { + applyCrd(Files.readString(path), pathAsString, getKubernetesClient()); + } catch (IOException e) { + throw new IllegalStateException("Cannot open CRD file at " + path.toAbsolutePath(), e); + } + crdMappings.remove(resourceTypeName); + } + /** * Applies the CRD associated with the specified resource type name, first checking if a CRD has * been manually specified using {@link Builder#withAdditionalCRD}, otherwise assuming that its @@ -209,13 +239,7 @@ public void applyCrd(String resourceTypeName) { // first attempt to use a manually defined CRD final var pathAsString = crdMappings.get(resourceTypeName); if (pathAsString != null) { - final var path = Path.of(pathAsString); - try { - applyCrd(Files.readString(path), pathAsString, getKubernetesClient()); - } catch (IOException e) { - throw new IllegalStateException("Cannot open CRD file at " + path.toAbsolutePath(), e); - } - crdMappings.remove(resourceTypeName); + applyCrdFromMappings(pathAsString, resourceTypeName); } else { // if no manually defined CRD matches the resource type, apply the generated one applyCrd(resourceTypeName, getKubernetesClient()); @@ -280,6 +304,7 @@ protected void before(ExtensionContext context) { } additionalCustomResourceDefinitions.forEach(this::applyCrd); + additionalCustomResourceDefinitionInstances.forEach(this::applyCrd); for (var ref : reconcilers) { final var config = operator.getConfigurationService().getConfigurationFor(ref.reconciler); final var oconfig = override(config); @@ -361,24 +386,60 @@ private void deleteCrd(AppliedCRD appliedCRD, KubernetesClient client) { LOGGER.debug("Skipping deleting CRD because of configuration: {}", appliedCRD); return; } - try { - LOGGER.debug("Deleting CRD: {}", appliedCRD.crdString); - final var crd = client.load(new ByteArrayInputStream(appliedCRD.crdString.getBytes())); - crd.withTimeoutInMillis(CRD_DELETE_TIMEOUT).delete(); - LOGGER.debug("Deleted CRD with path: {}", appliedCRD.path); - } catch (Exception ex) { - LOGGER.warn( - "Cannot delete CRD yaml: {}. You might need to delete it manually.", appliedCRD.path, ex); - } + appliedCRD.delete(client); } - private record AppliedCRD(String crdString, String path) {} + private sealed interface AppliedCRD permits AppliedCRD.FileCRD, AppliedCRD.InstanceCRD { + /** + * Delete this CRD from the cluster + * + * @param client client to use for deletion + */ + void delete(KubernetesClient client); + + record FileCRD(String crdString, String path) implements AppliedCRD { + + @Override + public void delete(KubernetesClient client) { + try { + LOGGER.debug("Deleting CRD: {}", crdString); + final var crd = client.load(new ByteArrayInputStream(crdString.getBytes())); + crd.withTimeoutInMillis(CRD_DELETE_TIMEOUT).delete(); + LOGGER.debug("Deleted CRD with path: {}", path); + } catch (Exception ex) { + LOGGER.warn( + "Cannot delete CRD yaml: {}. You might need to delete it manually.", path, ex); + } + } + } + + record InstanceCRD(CustomResourceDefinition customResourceDefinition) implements AppliedCRD { + + @Override + public void delete(KubernetesClient client) { + String type = customResourceDefinition.getMetadata().getName(); + try { + LOGGER.debug("Deleting CustomResourceDefinition instance CRD: {}", type); + final var crd = client.resource(customResourceDefinition); + crd.withTimeoutInMillis(CRD_DELETE_TIMEOUT).delete(); + LOGGER.debug("Deleted CustomResourceDefinition instance CRD: {}", type); + } catch (Exception ex) { + LOGGER.warn( + "Cannot delete CustomResourceDefinition instance CRD: {}. You might need to delete it" + + " manually.", + type, + ex); + } + } + } + } @SuppressWarnings("rawtypes") public static class Builder extends AbstractBuilder { private final List reconcilers; private final List portForwards; private final List> additionalCustomResourceDefinitions; + private final List additionalCustomResourceDefinitionInstances; private final List additionalCRDs = new ArrayList<>(); private Consumer beforeStartHook; private KubernetesClient kubernetesClient; @@ -389,6 +450,7 @@ protected Builder() { this.reconcilers = new ArrayList<>(); this.portForwards = new ArrayList<>(); this.additionalCustomResourceDefinitions = new ArrayList<>(); + this.additionalCustomResourceDefinitionInstances = new ArrayList<>(); } public Builder withReconciler( @@ -449,6 +511,11 @@ public Builder withAdditionalCustomResourceDefinition( return this; } + public Builder withAdditionalCustomResourceDefinition(CustomResourceDefinition definition) { + additionalCustomResourceDefinitionInstances.add(definition); + return this; + } + public Builder withAdditionalCRD(String... paths) { if (paths != null) { additionalCRDs.addAll(List.of(paths)); @@ -471,6 +538,7 @@ public LocallyRunOperatorExtension build() { infrastructure, portForwards, additionalCustomResourceDefinitions, + additionalCustomResourceDefinitionInstances, infrastructureTimeout, preserveNamespaceOnError, waitForNamespaceDeletion, 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 366698fc66..72614e3fb2 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java @@ -16,14 +16,21 @@ package io.javaoperatorsdk.operator; import java.time.Duration; +import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinitionBuilder; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinitionList; +import io.fabric8.kubernetes.api.model.apiextensions.v1.JSONSchemaPropsBuilder; import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.Resource; import io.fabric8.kubernetes.model.annotation.Group; import io.fabric8.kubernetes.model.annotation.Kind; import io.fabric8.kubernetes.model.annotation.Version; @@ -44,7 +51,8 @@ Demonstrates how to manually specify and apply Custom Resource Definitions (CRDs) in \ integration tests using the LocallyRunOperatorExtension. This test verifies that CRDs \ can be loaded from specified file paths and properly registered with the Kubernetes API \ - server during test execution. + server during test execution. It also verifies that CustomResourceDefinition instances + with no corresponding file can be applied. """) public class CRDMappingInTestExtensionIT { private final KubernetesClient client = new KubernetesClientBuilder().build(); @@ -54,34 +62,75 @@ public class CRDMappingInTestExtensionIT { LocallyRunOperatorExtension.builder() .withReconciler(new TestReconciler()) .withAdditionalCRD("src/test/resources/crd/test.crd", "src/test/crd/test.crd") + .withAdditionalCustomResourceDefinition(testCRD()) .build(); + public static CustomResourceDefinition testCRD() { + return new CustomResourceDefinitionBuilder() + .editOrNewSpec() + .withScope("Cluster") + .withGroup("operator.javaoperatorsdk.io") + .editOrNewNames() + .withPlural("tests") + .withSingular("test") + .withKind("Test") + .endNames() + .addNewVersion() + .withName("v1") + .withServed(true) + .withStorage(true) + .withNewSchema() + .withNewOpenAPIV3Schema() + .withType("object") + .withProperties(Map.of("bar", new JSONSchemaPropsBuilder().withType("string").build())) + .endOpenAPIV3Schema() + .endSchema() + .endVersion() + .and() + .editOrNewMetadata() + .withName("tests.operator.javaoperatorsdk.io") + .and() + .build(); + } + @Test void correctlyAppliesManuallySpecifiedCRD() { final var crdClient = client.apiextensions().v1().customResourceDefinitions(); + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted(() -> assertCrdApplied(crdClient, "tests.crd.example", "foo")); await() .pollDelay(Duration.ofMillis(150)) .untilAsserted( - () -> { - final var actual = crdClient.withName("tests.crd.example").get(); - assertThat(actual).isNotNull(); - assertThat( - actual - .getSpec() - .getVersions() - .get(0) - .getSchema() - .getOpenAPIV3Schema() - .getProperties() - .containsKey("foo")) - .isTrue(); - }); + () -> assertCrdApplied(crdClient, "tests.operator.javaoperatorsdk.io", "bar")); await() .pollDelay(Duration.ofMillis(150)) .untilAsserted( () -> assertThat(crdClient.withName("externals.crd.example").get()).isNotNull()); } + private static void assertCrdApplied( + NonNamespaceOperation< + CustomResourceDefinition, + CustomResourceDefinitionList, + Resource> + crdClient, + String s, + String propertyName) { + final var actual = crdClient.withName(s).get(); + assertThat(actual).isNotNull(); + assertThat( + actual + .getSpec() + .getVersions() + .get(0) + .getSchema() + .getOpenAPIV3Schema() + .getProperties() + .containsKey(propertyName)) + .isTrue(); + } + @Group("crd.example") @Version("v1") @Kind("Test")