Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public class LocallyRunOperatorExtension extends AbstractOperatorExtension {
private final List<PortForwardSpec> portForwards;
private final List<LocalPortForward> localPortForwards;
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
private final List<CustomResourceDefinition> additionalCustomResourceDefinitionInstances;
private final Map<Reconciler, RegisteredController> registeredControllers;
private final Map<String, String> crdMappings;
private final Consumer<LocallyRunOperatorExtension> beforeStartHook;
Expand All @@ -76,6 +77,7 @@ private LocallyRunOperatorExtension(
List<HasMetadata> infrastructure,
List<PortForwardSpec> portForwards,
List<Class<? extends CustomResource>> additionalCustomResourceDefinitions,
List<CustomResourceDefinition> additionalCustomResourceDefinitionInstances,
Duration infrastructureTimeout,
boolean preserveNamespaceOnError,
boolean waitForNamespaceDeletion,
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -195,6 +198,33 @@ public void applyCrd(Class<? extends CustomResource> crClass) {
applyCrd(ReconcilerUtils.getResourceTypeName(crClass));
}

public void applyCrd(CustomResourceDefinition customResourceDefinition) {
try {
String resourceTypeName = customResourceDefinition.getMetadata().getName();
final var pathAsString = crdMappings.get(resourceTypeName);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unsure whether this logic should be applied.

For the other mechanism withAdditionalCustomResourceDefinition(Class<? extends CustomResource> customResource) { it checks the custom crdMappings first and prefers to apply a CRD supplied there, else it looks in the standard location.

What should we do if the crdMappings contains a CRD with the same name as this CustomResourceDefinition?

Copy link
Collaborator

@csviri csviri Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, we should check if we already applied CRD with same name, if yes throw an exception, since probably that is not what user intends. This can be a separate PR.

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
Expand All @@ -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());
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Builder> {
private final List<ReconcilerSpec> reconcilers;
private final List<PortForwardSpec> portForwards;
private final List<Class<? extends CustomResource>> additionalCustomResourceDefinitions;
private final List<CustomResourceDefinition> additionalCustomResourceDefinitionInstances;
private final List<String> additionalCRDs = new ArrayList<>();
private Consumer<LocallyRunOperatorExtension> beforeStartHook;
private KubernetesClient kubernetesClient;
Expand All @@ -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(
Expand Down Expand Up @@ -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));
Expand All @@ -471,6 +538,7 @@ public LocallyRunOperatorExtension build() {
infrastructure,
portForwards,
additionalCustomResourceDefinitions,
additionalCustomResourceDefinitionInstances,
infrastructureTimeout,
preserveNamespaceOnError,
waitForNamespaceDeletion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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<CustomResourceDefinition>>
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")
Expand Down