diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index c66adb0b55..68213a7907 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -50,13 +50,14 @@ + io.fabric8 openshift-client - + org.slf4j slf4j-api @@ -97,5 +98,15 @@ log4j-core test + + io.fabric8 + kubernetes-server-mock + test + + + org.awaitility + awaitility + test + diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/Controller.java index 90bd70d8b3..4c332ebf24 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/Controller.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/Controller.java @@ -40,4 +40,13 @@ * @return the list of namespaces this controller monitors */ String[] namespaces() default {}; + + /** + * Optional label selector used to identify the set of custom resources the controller will acc + * upon. The label selector can be made of multiple comma separated requirements that acts as a + * logical AND operator. + * + * @return the finalizer name + */ + String labelSelector() default NULL; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractControllerConfiguration.java index 2bd7c4448d..d6e6371750 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractControllerConfiguration.java @@ -15,6 +15,7 @@ public abstract class AbstractControllerConfiguration private final Set namespaces; private final boolean watchAllNamespaces; private final RetryConfiguration retryConfiguration; + private final String labelSelector; private ConfigurationService service; public AbstractControllerConfiguration( @@ -24,7 +25,8 @@ public AbstractControllerConfiguration( String finalizer, boolean generationAware, Set namespaces, - RetryConfiguration retryConfiguration) { + RetryConfiguration retryConfiguration, + String labelSelector) { this.associatedControllerClassName = associatedControllerClassName; this.name = name; this.crdName = crdName; @@ -37,6 +39,25 @@ public AbstractControllerConfiguration( retryConfiguration == null ? ControllerConfiguration.super.getRetryConfiguration() : retryConfiguration; + this.labelSelector = labelSelector; + } + + /** + * @deprecated use + * {@link #AbstractControllerConfiguration(String, String, String, String, boolean, Set, RetryConfiguration, String)} + * instead + */ + @Deprecated + public AbstractControllerConfiguration( + String associatedControllerClassName, + String name, + String crdName, + String finalizer, + boolean generationAware, + Set namespaces, + RetryConfiguration retryConfiguration) { + this(associatedControllerClassName, name, crdName, finalizer, generationAware, namespaces, + retryConfiguration, null); } @Override @@ -88,4 +109,9 @@ public ConfigurationService getConfigurationService() { public void setConfigurationService(ConfigurationService service) { this.service = service; } + + @Override + public String getLabelSelector() { + return labelSelector; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java index 95555c511d..89d4872303 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java @@ -1,21 +1,46 @@ package io.javaoperatorsdk.operator.api.config; import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.ControllerUtils; import io.javaoperatorsdk.operator.api.Controller; +import java.lang.reflect.ParameterizedType; import java.util.Collections; import java.util.Set; public interface ControllerConfiguration { - String getName(); + default String getName() { + return ControllerUtils.getDefaultResourceControllerName(getAssociatedControllerClassName()); + } - String getCRDName(); + default String getCRDName() { + return CustomResource.getCRDName(getCustomResourceClass()); + } - String getFinalizer(); + default String getFinalizer() { + return ControllerUtils.getDefaultFinalizerName(getCRDName()); + } + + /** + * Retrieves the label selector that is used to filter which custom resources are actually watched + * by the associated controller. See + * https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ for more details on + * syntax. + * + * @return the label selector filtering watched custom resources + */ + default String getLabelSelector() { + return null; + } - boolean isGenerationAware(); + default boolean isGenerationAware() { + return true; + } - Class getCustomResourceClass(); + default Class getCustomResourceClass() { + ParameterizedType type = (ParameterizedType) getClass().getGenericInterfaces()[0]; + return (Class) type.getActualTypeArguments()[0]; + } String getAssociatedControllerClassName(); @@ -67,7 +92,7 @@ default RetryConfiguration getRetryConfiguration() { ConfigurationService getConfigurationService(); - void setConfigurationService(ConfigurationService service); + default void setConfigurationService(ConfigurationService service) {} default boolean useFinalizer() { return !Controller.NO_FINALIZER.equals(getFinalizer()); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java index 10743fba7f..cc6d5f75ec 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java @@ -11,6 +11,7 @@ public class ControllerConfigurationOverrider { private boolean generationAware; private Set namespaces; private RetryConfiguration retry; + private String labelSelector; private final ControllerConfiguration original; private ControllerConfigurationOverrider(ControllerConfiguration original) { @@ -18,6 +19,7 @@ private ControllerConfigurationOverrider(ControllerConfiguration original) { generationAware = original.isGenerationAware(); namespaces = new HashSet<>(original.getNamespaces()); retry = original.getRetryConfiguration(); + labelSelector = original.getLabelSelector(); this.original = original; } @@ -57,6 +59,11 @@ public ControllerConfigurationOverrider withRetry(RetryConfiguration retry) { return this; } + public ControllerConfigurationOverrider withLabelSelector(String labelSelector) { + this.labelSelector = labelSelector; + return this; + } + public ControllerConfiguration build() { return new AbstractControllerConfiguration( original.getAssociatedControllerClassName(), @@ -65,7 +72,8 @@ public ControllerConfiguration build() { finalizer, generationAware, namespaces, - retry) { + retry, + labelSelector) { @Override public Class getCustomResourceClass() { return original.getCustomResourceClass(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSource.java index c2538fda3a..728b52823c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSource.java @@ -5,12 +5,14 @@ import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; import io.fabric8.kubernetes.api.model.KubernetesResourceList; +import io.fabric8.kubernetes.api.model.ListOptions; import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.Watch; import io.fabric8.kubernetes.client.Watcher; import io.fabric8.kubernetes.client.WatcherException; import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.utils.Utils; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.processing.CustomResourceCache; import io.javaoperatorsdk.operator.processing.KubernetesResourceUtils; @@ -33,6 +35,7 @@ public class CustomResourceEventSource> extends A private final Set targetNamespaces; private final boolean generationAware; private final String resourceFinalizer; + private final String labelSelector; private final Map lastGenerationProcessedSuccessfully = new ConcurrentHashMap<>(); private final List watches; private final String resClass; @@ -46,6 +49,7 @@ public CustomResourceEventSource( configuration.getEffectiveNamespaces(), configuration.isGenerationAware(), configuration.getFinalizer(), + configuration.getLabelSelector(), configuration.getCustomResourceClass(), new CustomResourceCache(configuration.getConfigurationService().getObjectMapper())); } @@ -55,12 +59,14 @@ public CustomResourceEventSource( Set targetNamespaces, boolean generationAware, String resourceFinalizer, + String labelSelector, Class resClass) { this( client, targetNamespaces, generationAware, resourceFinalizer, + labelSelector, resClass, new CustomResourceCache()); } @@ -70,12 +76,14 @@ public CustomResourceEventSource( Set targetNamespaces, boolean generationAware, String resourceFinalizer, + String labelSelector, Class resClass, CustomResourceCache customResourceCache) { this.client = client; this.targetNamespaces = targetNamespaces; this.generationAware = generationAware; this.resourceFinalizer = resourceFinalizer; + this.labelSelector = labelSelector; this.watches = new ArrayList<>(); this.resClass = resClass.getName(); this.customResourceCache = customResourceCache; @@ -83,14 +91,19 @@ public CustomResourceEventSource( @Override public void start() { + var options = new ListOptions(); + if (Utils.isNotNullOrEmpty(labelSelector)) { + options.setLabelSelector(labelSelector); + } + if (ControllerConfiguration.allNamespacesWatched(targetNamespaces)) { - var w = client.inAnyNamespace().watch(this); + var w = client.inAnyNamespace().watch(options, this); watches.add(w); log.debug("Registered controller {} -> {} for any namespace", resClass, w); } else { targetNamespaces.forEach( ns -> { - var w = client.inNamespace(ns).watch(this); + var w = client.inNamespace(ns).watch(options, this); watches.add(w); log.debug("Registered controller {} -> {} for namespace: {}", resClass, w, ns); }); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationTest.java new file mode 100644 index 0000000000..1b58b9e9e7 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationTest.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.api.config; + +import static org.junit.jupiter.api.Assertions.*; + +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; +import org.junit.jupiter.api.Test; + +class ControllerConfigurationTest { + + @Test + void getCustomResourceClass() { + final ControllerConfiguration conf = new ControllerConfiguration<>() { + @Override + public String getAssociatedControllerClassName() { + return null; + } + + @Override + public ConfigurationService getConfigurationService() { + return null; + } + }; + assertEquals(TestCustomResource.class, conf.getCustomResourceClass()); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSourceTest.java index da8639b1a0..1e1a3513b5 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceEventSourceTest.java @@ -25,7 +25,8 @@ class CustomResourceEventSourceTest { EventHandler eventHandler = mock(EventHandler.class); private CustomResourceEventSource customResourceEventSource = - new CustomResourceEventSource<>(client, null, true, FINALIZER, TestCustomResource.class); + new CustomResourceEventSource<>( + client, null, true, FINALIZER, null, TestCustomResource.class); @BeforeEach public void setup() { @@ -72,7 +73,8 @@ public void normalExecutionIfGenerationChanges() { @Test public void handlesAllEventIfNotGenerationAware() { customResourceEventSource = - new CustomResourceEventSource<>(client, null, false, FINALIZER, TestCustomResource.class); + new CustomResourceEventSource<>( + client, null, false, FINALIZER, null, TestCustomResource.class); setup(); TestCustomResource customResource1 = TestUtils.testCustomResource(); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceSelectorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceSelectorTest.java new file mode 100644 index 0000000000..74be731334 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/internal/CustomResourceSelectorTest.java @@ -0,0 +1,186 @@ +package io.javaoperatorsdk.operator.processing.event.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.VersionInfo; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.Context; +import io.javaoperatorsdk.operator.api.Controller; +import io.javaoperatorsdk.operator.api.ResourceController; +import io.javaoperatorsdk.operator.api.UpdateControl; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.Version; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; +import java.text.ParseException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.awaitility.core.ConditionTimeoutException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@EnableKubernetesMockClient(crud = true, https = false) +public class CustomResourceSelectorTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomResourceSelectorTest.class); + + KubernetesMockServer server; + KubernetesClient client; + ConfigurationService configurationService; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUpResources() throws ParseException { + String buildDate = + DateTimeFormatter.ofPattern(VersionInfo.VersionKeys.BUILD_DATE_FORMAT) + .format(LocalDateTime.now()); + + server + .expect() + .get() + .withPath("/version") + .andReturn( + 200, + new VersionInfo.Builder() + .withBuildDate(buildDate) + .withMajor("1") + .withMinor("21") + .build()) + .always(); + + configurationService = spy(ConfigurationService.class); + when(configurationService.checkCRDAndValidateLocalModel()).thenReturn(false); + when(configurationService.getVersion()).thenReturn(new Version("1", "1", new Date())); + when(configurationService.getConfigurationFor(any(MyController.class))).thenReturn( + new MyConfiguration(configurationService, null)); + } + + @Test + void resourceWatchedByLabel() { + assertThat(server).isNotNull(); + assertThat(client).isNotNull(); + + try (Operator o1 = new Operator(client, configurationService); + Operator o2 = new Operator(client, configurationService)) { + + AtomicInteger c1 = new AtomicInteger(); + AtomicInteger c1err = new AtomicInteger(); + AtomicInteger c2 = new AtomicInteger(); + AtomicInteger c2err = new AtomicInteger(); + + o1.register( + new MyController( + resource -> { + if ("foo".equals(resource.getMetadata().getName())) { + c1.incrementAndGet(); + } + if ("bar".equals(resource.getMetadata().getName())) { + c1err.incrementAndGet(); + } + }), + new MyConfiguration(configurationService, "app=foo")); + o1.start(); + o2.register( + new MyController( + resource -> { + if ("bar".equals(resource.getMetadata().getName())) { + c2.incrementAndGet(); + } + if ("foo".equals(resource.getMetadata().getName())) { + c2err.incrementAndGet(); + } + }), + new MyConfiguration(configurationService, "app=bar")); + o2.start(); + + client.resources(TestCustomResource.class).inNamespace("test").create(newMyResource("foo")); + client.resources(TestCustomResource.class).inNamespace("test").create(newMyResource("bar")); + + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> c1.get() == 1 && c1err.get() == 0); + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> c2.get() == 1 && c2err.get() == 0); + + assertThrows( + ConditionTimeoutException.class, + () -> await().atMost(2, TimeUnit.SECONDS).untilAtomic(c1err, is(greaterThan(0)))); + assertThrows( + ConditionTimeoutException.class, + () -> await().atMost(2, TimeUnit.SECONDS).untilAtomic(c2err, is(greaterThan(0)))); + } + } + + public TestCustomResource newMyResource(String app) { + TestCustomResource resource = new TestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(app).addToLabels("app", app).build()); + return resource; + } + + public static class MyConfiguration implements ControllerConfiguration { + + private final String labelSelector; + private final ConfigurationService service; + + public MyConfiguration(ConfigurationService configurationService, String labelSelector) { + this.labelSelector = labelSelector; + service = configurationService; + } + + @Override + public String getLabelSelector() { + return labelSelector; + } + + @Override + public String getAssociatedControllerClassName() { + return MyController.class.getCanonicalName(); + } + + @Override + public ConfigurationService getConfigurationService() { + return service; + } + } + + @Controller + public static class MyController implements ResourceController { + + private final Consumer consumer; + + public MyController(Consumer consumer) { + this.consumer = consumer; + } + + @Override + public UpdateControl createOrUpdateResource( + TestCustomResource resource, Context context) { + + LOGGER.info("Received event on: {}", resource); + + consumer.accept(resource); + + return UpdateControl.updateStatusSubResource(resource); + } + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AnnotationConfiguration.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AnnotationConfiguration.java index 81f6c521b8..dc03670ff6 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AnnotationConfiguration.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AnnotationConfiguration.java @@ -27,11 +27,6 @@ public String getName() { return ControllerUtils.getNameFor(controller); } - @Override - public String getCRDName() { - return CustomResource.getCRDName(getCustomResourceClass()); - } - @Override public String getFinalizer() { return annotation @@ -70,3 +65,4 @@ public String getAssociatedControllerClassName() { return controller.getClass().getCanonicalName(); } } + diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationService.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationService.java index 013f52855a..590b9fb746 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationService.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationService.java @@ -29,8 +29,8 @@ ControllerConfiguration getConfigurationFor( var config = super.getConfigurationFor(controller); if (config == null) { if (createIfNeeded) { - // create the the configuration on demand and register it - config = new AnnotationConfiguration(controller); + // create the configuration on demand and register it + config = new AnnotationConfiguration<>(controller); register(config); log.info( "Created configuration for controller {} with name {}",