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 {}",