From 4316634b74b69af163e1f95221b3555a8d121bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 16:04:37 +0200 Subject: [PATCH 01/10] feat: informer pool to share informers across controllers and event sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an informer pool so that InformerEventSources watching the same resource type with an equivalent configuration share a single underlying fabric8 SharedIndexInformer instead of creating one per event source. This reduces memory usage and the number of watch connections opened against the API server when many controllers watch the same (secondary) resource type. - Add an InformerPool abstraction with two strategies: - DefaultInformerPool (default): reference-counted sharing of informers keyed by an InformerClassifier (API server URL, resource type / GVK, namespace, label/field/shard selectors, item store). The informer is created on first use and stopped only when the last user releases it. informerListLimit is intentionally not part of the identity; indexers are added independently. - AlwaysNewInformerPool: never shares (one informer per event source), to opt out of pooling and keep the previous behavior. - Resolve the pool via ConfigurationService.informerPool() (an abstract method, cached as a per-ConfigurationService singleton in AbstractConfigurationService) and expose ConfigurationServiceOverrider.withInformerPool(...) to select the strategy. - Route InformerManager / ManagedInformerEventSource through the pool. Dynamic event source registration reuses an already-running informer and relies on the informer replaying its cache to the newly added handler for initial state. - Add group/version/kind and field-selector support to the classifier. - Tests: unit tests for InformerClassifier equality and pool reference counting; integration tests for basic sharing, dynamic registration and de-registration, each run against both pool strategies. - Add documentation for informer pooling. The feature is marked @Experimental: the runtime behavior is production-ready, but the configuration API may still change in a non-backwards-compatible way. Signed-off-by: Attila Mészáros --- .../content/en/docs/documentation/eventing.md | 55 +++- .../config/AbstractConfigurationService.java | 14 + .../api/config/ConfigurationService.java | 18 ++ .../config/ConfigurationServiceOverrider.java | 20 ++ .../api/config/informer/FieldSelector.java | 13 + .../informer/InformerConfiguration.java | 21 +- .../InformerEventSourceConfiguration.java | 2 +- .../operator/processing/GroupVersionKind.java | 4 + .../processing/event/EventSourceManager.java | 6 +- .../processing/event/source/EventSource.java | 23 +- .../controller/ControllerEventSource.java | 2 +- .../source/informer/InformerEventSource.java | 18 +- .../source/informer/InformerManager.java | 140 ++++----- .../source/informer/InformerWrapper.java | 118 +------- .../informer/ManagedInformerEventSource.java | 21 +- .../informer/pool/AbstractInformerPool.java | 199 +++++++++++++ .../informer/pool/AlwaysNewInformerPool.java | 78 ++++++ .../informer/pool/DefaultInformerPool.java | 141 ++++++++++ .../informer/pool/InformerClassifier.java | 78 ++++++ .../source/informer/pool/InformerPool.java | 58 ++++ .../operator/MockKubernetesClient.java | 12 +- .../operator/processing/ControllerTest.java | 12 +- .../event/EventSourceManagerTest.java | 15 +- .../event/ReconciliationDispatcherTest.java | 17 +- .../source/AbstractEventSourceTestBase.java | 2 +- .../controller/ControllerEventSourceTest.java | 7 +- .../informer/InformerEventSourceTest.java | 27 +- .../pool/AlwaysNewInformerPoolTest.java | 129 +++++++++ .../pool/DefaultInformerPoolTest.java | 128 +++++++++ .../informer/pool/InformerClassifierTest.java | 265 ++++++++++++++++++ .../basic/AbstractSharedInformerIT.java | 105 +++++++ ...AlwaysNewInformerPoolSharedInformerIT.java | 39 +++ .../basic/SharedInformerCustomResource1.java | 28 ++ .../basic/SharedInformerCustomResource2.java | 28 ++ .../informerpool/basic/SharedInformerIT.java | 38 +++ .../basic/SharedInformerReconciler1.java | 63 +++++ .../basic/SharedInformerReconciler2.java | 62 ++++ .../basic/SharedInformerStatus.java | 29 ++ .../AbstractDeregisterSharedInformerIT.java | 97 +++++++ ...nformerPoolDeregisterSharedInformerIT.java | 34 +++ .../DeregisterPrimaryCustomResource.java | 29 ++ .../deregister/DeregisterReconciler.java | 73 +++++ .../DeregisterSharedInformerIT.java | 29 ++ .../deregister/DeregisterSpec.java | 37 +++ .../DeregisterWatchedCustomResource.java | 32 +++ .../AbstractDynamicSharedInformerIT.java | 138 +++++++++ ...ewInformerPoolDynamicSharedInformerIT.java | 41 +++ .../dynamic/DynamicSharedInformerIT.java | 37 +++ ...cSharedInformerPrimaryCustomResource1.java | 29 ++ ...cSharedInformerPrimaryCustomResource2.java | 29 ++ .../DynamicSharedInformerReconciler.java | 81 ++++++ ...amicSharedInformerThirdCustomResource.java | 34 +++ .../StaticSharedInformerReconciler.java | 73 +++++ .../StandaloneDependentResourceIT.java | 7 + 54 files changed, 2605 insertions(+), 230 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifier.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifierTest.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AbstractSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AlwaysNewInformerPoolSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource1.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource2.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler1.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler2.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerStatus.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AbstractDeregisterSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AlwaysNewInformerPoolDeregisterSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterPrimaryCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterWatchedCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AbstractDynamicSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AlwaysNewInformerPoolDynamicSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource1.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource2.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerThirdCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/StaticSharedInformerReconciler.java diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md index d2a104737b..e2c57d6217 100644 --- a/docs/content/en/docs/documentation/eventing.md +++ b/docs/content/en/docs/documentation/eventing.md @@ -346,4 +346,57 @@ for [primary resources](https://github.com/operator-framework/java-operator-sdk/ See also [CaffeineBoundedItemStores](https://github.com/operator-framework/java-operator-sdk/blob/main/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedItemStores.java) -for more details. \ No newline at end of file +for more details. + +### Sharing Informers Between Controllers (Informer Pool) + +{{% alert title="Experimental" color="warning" %}} +Informer pooling is marked `@Experimental`: the feature itself is production ready, but its +configuration API may still change in a non-backwards-compatible way. +{{% /alert %}} + +By default JOSDK maintains an *informer pool* so that informers are **shared** across controllers +and event sources. When several `InformerEventSource`s (whether belonging to different controllers, +or dynamically registered at runtime) watch the same resource type with an equivalent configuration, +they are all backed by a single underlying `SharedIndexInformer` instead of one informer each. This +reduces memory usage and the number of watch connections opened against the API server — which +matters in operators where many controllers watch the same secondary resource type (for example +`ConfigMap` or `Secret`). + +Two event sources share an informer when their effective informer configuration matches on all of: + +- the target cluster (API server URL, including the remote client used for + [multi-cluster](#informereventsource-multi-cluster-support) event sources), +- the resource type (or the group/version/kind for generic resources), +- the watched namespace, +- the label, field and shard selectors, +- the configured [item store](#bounded-caches-for-informers). + +The `informerListLimit` is intentionally *not* part of this identity: if two otherwise-equivalent +event sources request a different list limit, the existing informer is reused (a warning is logged +and the first-configured limit is kept). Indexers are also not part of the identity, since they can +be added to a shared informer independently; just make sure indexer names don't collide. + +The pool is reference-counted: the shared informer is created on first use and only stopped once the +last event source using it is de-registered (or its controller stops). Dynamically registering an +event source for a resource that is already backed by a running informer reuses that informer, and +the initial state already in its cache is replayed to the newly added handler. + +#### Selecting the pooling strategy + +The strategy is provided by the +[`InformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java) +configured on the `ConfigurationService`. Two implementations are available: + +- [`DefaultInformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java) + (the default): shares informers as described above. +- [`AlwaysNewInformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java): + never shares informers, creating a dedicated informer for every event source. Use this to opt out + of pooling and restore the pre-pooling behavior. + +You can override the strategy through the `ConfigurationService`: + +```java +Operator operator = new Operator(overrider -> + overrider.withInformerPool(new AlwaysNewInformerPool())); +``` \ No newline at end of file diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java index a1b37d6fe9..e4ce563b62 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java @@ -24,6 +24,8 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.DefaultInformerPool; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool; /** * An abstract implementation of {@link ConfigurationService} meant to ease custom implementations @@ -35,6 +37,7 @@ public class AbstractConfigurationService implements ConfigurationService { private KubernetesClient client; private Cloner cloner; private ExecutorServiceManager executorServiceManager; + private InformerPool informerPool; protected AbstractConfigurationService(Version version) { this(version, null); @@ -190,4 +193,15 @@ public ExecutorServiceManager getExecutorServiceManager() { } return executorServiceManager; } + + @Override + public synchronized InformerPool informerPool() { + // cached so that all controllers backed by this ConfigurationService share the same pool and + // can therefore share the underlying informers; synchronized so concurrent first-access from + // multiple controllers cannot create (and share out) more than one pool instance + if (informerPool == null) { + informerPool = new DefaultInformerPool(this); + } + return informerPool; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java index 6ed9b7ff64..28a15ec3d6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java @@ -43,6 +43,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig; import io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowFactory; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool; /** An interface from which to retrieve configuration information. */ public interface ConfigurationService { @@ -476,4 +477,21 @@ default boolean useSSAToPatchPrimaryResource() { default boolean cloneSecondaryResourcesWhenGettingFromCache() { return false; } + + /** + * The {@link InformerPool} used to create and (when using the default, sharing pool) share the + * informers backing the event sources of all controllers managed by this {@code + * ConfigurationService}. + * + *

Implementations must return the same instance on every call. The pool is + * effectively a per-{@code ConfigurationService} singleton: controllers share informers only if + * they resolve the same pool, and reference counting / informer shutdown are only correct if + * {@code getInformer} and {@code releaseInformer} operate on that same instance. This is + * intentionally not a {@code default} method, since a {@code default} could not cache the result + * and would hand out a fresh (unshared) pool on each call; {@link AbstractConfigurationService} + * provides a cached implementation backed by the default sharing pool. + * + * @return the informer pool for this configuration service + */ + InformerPool informerPool(); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java index cd9cdafb39..8d0835cd5e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java @@ -29,6 +29,7 @@ import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.api.monitoring.Metrics; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool; @SuppressWarnings({"unused", "UnusedReturnValue"}) public class ConfigurationServiceOverrider { @@ -53,6 +54,7 @@ public class ConfigurationServiceOverrider { private Set> defaultNonSSAResource; private Boolean useSSAToPatchPrimaryResource; private Boolean cloneSecondaryResourcesWhenGettingFromCache; + private InformerPool informerPool; @SuppressWarnings("rawtypes") private DependentResourceFactory dependentResourceFactory; @@ -176,6 +178,15 @@ public ConfigurationServiceOverrider withCloneSecondaryResourcesWhenGettingFromC return this; } + /** + * Overrides the {@link InformerPool} strategy used to create/share the informers backing the + * event sources. When not set, the default (informer-sharing) pool is used. + */ + public ConfigurationServiceOverrider withInformerPool(InformerPool informerPool) { + this.informerPool = informerPool; + return this; + } + public ConfigurationService build() { return new BaseConfigurationService(original.getVersion(), cloner, client) { @Override @@ -309,6 +320,15 @@ public boolean cloneSecondaryResourcesWhenGettingFromCache() { cloneSecondaryResourcesWhenGettingFromCache, ConfigurationService::cloneSecondaryResourcesWhenGettingFromCache); } + + @Override + public InformerPool informerPool() { + if (informerPool == null) { + return super.informerPool(); + } + informerPool.setConfigurationService(this); + return informerPool; + } }; } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java index 022bb59ef0..26102dc314 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java @@ -17,6 +17,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Objects; public class FieldSelector { private final List fields; @@ -38,4 +39,16 @@ public Field(String path, String value) { this(path, value, false); } } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + FieldSelector that = (FieldSelector) o; + return Objects.equals(fields, that.fields); + } + + @Override + public int hashCode() { + return Objects.hashCode(fields); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java index 6c92dcdcc1..a1d9f0a746 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java @@ -30,6 +30,7 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Utils; import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; import io.javaoperatorsdk.operator.processing.event.source.cache.BoundedItemStore; import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter; @@ -42,6 +43,7 @@ public class InformerConfiguration { private final Builder builder = new Builder(); private final Class resourceClass; + private final GroupVersionKind resourceGroupVersionKind; private final String resourceTypeName; private String name; private Set namespaces; @@ -59,6 +61,7 @@ public class InformerConfiguration { protected InformerConfiguration( Class resourceClass, + GroupVersionKind resourceGroupVersionKind, String name, Set namespaces, boolean followControllerNamespaceChanges, @@ -74,7 +77,7 @@ protected InformerConfiguration( Boolean comparableResourceVersions, // TODO for removal in major release Duration ghostResourceCacheCheckInterval) { - this(resourceClass); + this(resourceClass, resourceGroupVersionKind); this.name = name; this.namespaces = namespaces; this.followControllerNamespaceChanges = followControllerNamespaceChanges; @@ -90,8 +93,9 @@ protected InformerConfiguration( this.comparableResourceVersions = comparableResourceVersions; } - private InformerConfiguration(Class resourceClass) { + private InformerConfiguration(Class resourceClass, GroupVersionKind resourceGroupVersionKind) { this.resourceClass = resourceClass; + this.resourceGroupVersionKind = resourceGroupVersionKind; this.resourceTypeName = resourceClass.isAssignableFrom(GenericKubernetesResource.class) // in general this is irrelevant now for secondary resources it is used just by @@ -101,10 +105,16 @@ private InformerConfiguration(Class resourceClass) { : ReconcilerUtilsInternal.getResourceTypeName(resourceClass); } + @SuppressWarnings({"rawtypes", "unchecked"}) + public static InformerConfiguration.Builder builder( + Class resourceClass, GroupVersionKind groupVersionKind) { + return new InformerConfiguration(resourceClass, groupVersionKind).builder; + } + @SuppressWarnings({"rawtypes", "unchecked"}) public static InformerConfiguration.Builder builder( Class resourceClass) { - return new InformerConfiguration(resourceClass).builder; + return new InformerConfiguration(resourceClass, null).builder; } @SuppressWarnings({"rawtypes", "unchecked"}) @@ -112,6 +122,7 @@ public static InformerConfiguration.Builder builder( InformerConfiguration original) { return new InformerConfiguration( original.resourceClass, + original.resourceGroupVersionKind, original.name, original.namespaces, original.followControllerNamespaceChanges, @@ -305,6 +316,10 @@ public Long getInformerListLimit() { return informerListLimit; } + public GroupVersionKind getResourceGroupVersionKind() { + return resourceGroupVersionKind; + } + public FieldSelector getFieldSelector() { return fieldSelector; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java index ab1ad2b8eb..c04ec3f07b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java @@ -167,7 +167,7 @@ private Builder( this.resourceClass = resourceClass; this.groupVersionKind = groupVersionKind; this.primaryResourceClass = primaryResourceClass; - this.config = InformerConfiguration.builder(resourceClass); + this.config = InformerConfiguration.builder(resourceClass, groupVersionKind); } public Builder withName(String name) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java index be3869a64f..7d182cf1e6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java @@ -136,4 +136,8 @@ public int hashCode() { public String toString() { return toGVKString(); } + + public String getApiVersion() { + return apiVersion; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java index 441d3cf178..83f3648dae 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java @@ -127,7 +127,7 @@ private void logEventSourceEvent(EventSource eventSource, String event) { private Void startEventSource(EventSource eventSource) { try { logEventSourceEvent(eventSource, "Starting"); - eventSource.start(); + eventSource.start(false); logEventSourceEvent(eventSource, "Started"); } catch (MissingCRDException e) { throw e; // leave untouched @@ -148,7 +148,7 @@ private Void stopEventSource(EventSource eventSource) { return null; } - @SuppressWarnings("rawtypes") + @SuppressWarnings({"rawtypes", "unchecked"}) public final synchronized void registerEventSource(EventSource eventSource) throws OperatorException { Objects.requireNonNull(eventSource, "EventSource must not be null"); @@ -251,7 +251,7 @@ public EventSource dynamicallyRegisterEventSource(EventSource ev } // The start itself is blocking thus blocking only the threads which are attempt to start the // actual event source. Think of this as a form of lock striping. - eventSource.start(); + eventSource.start(true); return eventSource; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java index 9bd301d77a..cc4e103e0d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java @@ -19,9 +19,9 @@ import java.util.Set; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.health.EventSourceHealthIndicator; import io.javaoperatorsdk.operator.health.Status; -import io.javaoperatorsdk.operator.processing.LifecycleAware; import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter; @@ -37,8 +37,7 @@ * @param

the primary resource type which reconciler needs to be triggered when events occur on * resources of type R */ -public interface EventSource - extends LifecycleAware, EventSourceHealthIndicator { +public interface EventSource extends EventSourceHealthIndicator { static String generateName(EventSource eventSource) { return eventSource.getClass().getName() + "@" + Integer.toHexString(eventSource.hashCode()); @@ -119,4 +118,22 @@ default Optional getSecondaryResource(P primary) { default Status getStatus() { return Status.UNKNOWN; } + + /** + * Start the event source. Normally, should synchronously populate caches, before the method + * returns. + * + * @since 5.6.0 + * @param dynamicRegistration true if event source registered dynamically, false otherwise + */ + default void start(boolean dynamicRegistration) { + start(); + } + + /** Only for backwards compatible purposes. Use {@link #start(boolean)}. */ + @Deprecated(forRemoval = true) + void start() throws OperatorException; + + /** Strop the event source */ + void stop() throws OperatorException; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index 2f624d1150..13d199bb59 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -48,7 +48,7 @@ public class ControllerEventSource @SuppressWarnings({"unchecked", "rawtypes"}) public ControllerEventSource(Controller controller) { - super(NAME, controller.getCRClient(), controller.getConfiguration()); + super(NAME, controller.getConfiguration()); this.controller = controller; final var config = controller.getConfiguration(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index b03a22e894..652c50b140 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -24,8 +24,6 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; @@ -56,18 +54,12 @@ public class InformerEventSource public InformerEventSource( InformerEventSourceConfiguration configuration, EventSourceContext

context) { - this(configuration, configuration.getKubernetesClient().orElse(context.getClient())); + this(configuration); } @SuppressWarnings({"unchecked", "rawtypes"}) - InformerEventSource(InformerEventSourceConfiguration configuration, KubernetesClient client) { - super( - configuration.name(), - configuration - .getGroupVersionKind() - .map(gvk -> client.genericKubernetesResources(gvk.apiVersion(), gvk.getKind())) - .orElseGet(() -> (MixedOperation) client.resources(configuration.getResourceClass())), - configuration); + InformerEventSource(InformerEventSourceConfiguration configuration) { + super(configuration.name(), configuration); // If there is a primary to secondary mapper there is no need for primary to secondary index. primaryToSecondaryMapper = configuration.getPrimaryToSecondaryMapper(); if (useSecondaryToPrimaryIndex()) { @@ -175,11 +167,11 @@ protected void handleEvent( } @Override - public synchronized void start() { + public synchronized void start(boolean dynamicRegistration) { if (isRunning()) { return; } - super.start(); + super.start(dynamicRegistration); // this makes sure that on first reconciliation all resources are // present on the index manager().list().forEach(r -> primaryToSecondaryIndex.onAddOrUpdate(r, null)); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java index 8e7054b231..d8c23fb68d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java @@ -26,50 +26,45 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.api.model.KubernetesResourceList; -import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Informable; import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; -import io.javaoperatorsdk.operator.processing.LifecycleAware; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.Cache; import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerClassifier; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool; import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACES; class InformerManager> - implements LifecycleAware, IndexerResourceCache { + implements IndexerResourceCache { private static final Logger log = LoggerFactory.getLogger(InformerManager.class); private final Map> sources = new ConcurrentHashMap<>(); private final C configuration; - private final MixedOperation, Resource> client; private final ResourceEventHandler eventHandler; private final Map>> indexers = new HashMap<>(); private ControllerConfiguration controllerConfiguration; + private InformerPool informerPool; - InformerManager( - MixedOperation, Resource> client, - C configuration, - ResourceEventHandler eventHandler) { - this.client = client; + InformerManager(C configuration, ResourceEventHandler eventHandler) { this.configuration = configuration; this.eventHandler = eventHandler; } void setControllerConfiguration(ControllerConfiguration controllerConfiguration) { this.controllerConfiguration = controllerConfiguration; + this.informerPool = controllerConfiguration.getConfigurationService().informerPool(); } - @Override public void start() throws OperatorException { initSources(); // make sure informers are all started before proceeding further @@ -78,8 +73,8 @@ public void start() throws OperatorException { .getExecutorServiceManager() .boundedExecuteAndWaitForAllToComplete( sources.values().stream(), - iw -> { - iw.start(); + wrapper -> { + start(wrapper); return null; }, iw -> @@ -96,12 +91,12 @@ private void initSources() { final var targetNamespaces = configuration.getInformerConfig().getEffectiveNamespaces(controllerConfiguration); if (InformerConfiguration.allNamespacesWatched(targetNamespaces)) { - var source = createEventSourceForNamespace(WATCH_ALL_NAMESPACES); + var source = getEventSourceForNamespace(WATCH_ALL_NAMESPACES); log.debug("Registered {} -> {} for any namespace", this, source); } else { targetNamespaces.forEach( ns -> { - final var source = createEventSourceForNamespace(ns); + final var source = getEventSourceForNamespace(ns); log.debug("Registered {} -> {} for namespace: {}", this, source, ns); }); } @@ -111,7 +106,16 @@ public void changeNamespaces(Set namespaces) { var sourcesToRemove = sources.keySet().stream().filter(k -> !namespaces.contains(k)).collect(Collectors.toSet()); log.debug("Stopped informer {} for namespaces: {}", this, sourcesToRemove); - sourcesToRemove.forEach(k -> sources.remove(k).stop()); + sourcesToRemove.forEach( + k -> { + var informer = + informerPool.releaseInformer( + controllerConfiguration.getName(), + configuration.getInformerConfig().getName(), + getClassifier(k)); + sources.remove(k); + informer.ifPresent(i -> i.removeEventHandler(eventHandler)); + }); var newNamespaces = namespaces.stream().filter(ns -> !sources.containsKey(ns)).collect(Collectors.toList()); @@ -125,76 +129,76 @@ public void changeNamespaces(Set namespaces) { .boundedExecuteAndWaitForAllToComplete( newNamespaces.stream(), ns -> { - final var source = createEventSourceForNamespace(ns); - source.start(); + final var source = getEventSourceForNamespace(ns); + // block until the informer's cache is synced (or the sync timeout elapses) + start(source); log.debug("Registered new {} -> {} for namespace: {}", this, source, ns); return null; }, ns -> "InformerStarter-" + ns + "-" + configuration.getResourceClass().getSimpleName()); } - private InformerWrapper createEventSourceForNamespace(String namespace) { + private void start(InformerWrapper informerWrapper) { + informerPool.start(informerWrapper.getInformer(), informerWrapper.getClassifier()); + } + + private InformerWrapper getEventSourceForNamespace(String namespaceIdentifier) { final InformerWrapper source; - final var labelSelector = configuration.getInformerConfig().getLabelSelector(); - final var shardSelector = configuration.getInformerConfig().getShardSelector(); - if (namespace.equals(WATCH_ALL_NAMESPACES)) { - final var filteredBySelectorClient = - client.inAnyNamespace().withLabelSelector(labelSelector).withShardSelector(shardSelector); - source = createEventSource(filteredBySelectorClient, eventHandler, WATCH_ALL_NAMESPACES); - } else { - source = - createEventSource( - client - .inNamespace(namespace) - .withLabelSelector(labelSelector) - .withShardSelector(shardSelector), - eventHandler, - namespace); - } + InformerClassifier classifier = getClassifier(namespaceIdentifier); + var informer = + informerPool.getInformer( + controllerConfiguration.getName(), + configuration.getInformerConfig().getName(), + classifier, + getTargetClient()); + source = new InformerWrapper<>(informer, namespaceIdentifier, classifier); + source.addEventHandler(eventHandler); + sources.put(namespaceIdentifier, source); source.addIndexers(indexers); return source; } - private InformerWrapper createEventSource( - FilterWatchListDeletable, Resource> filteredBySelectorClient, - ResourceEventHandler eventHandler, - String namespaceIdentifier) { - final var informerConfig = configuration.getInformerConfig(); + private InformerClassifier getClassifier(String namespaceIdentifier) { + KubernetesClient targetClient = getTargetClient(); + + return new InformerClassifier<>( + targetClient.getConfiguration().getMasterUrl(), + configuration.getInformerConfig().getLabelSelector(), + configuration.getInformerConfig().getShardSelector(), + namespaceIdentifier, + configuration.getResourceClass(), + configuration.getInformerConfig().getResourceGroupVersionKind(), + configuration.getInformerConfig().getFieldSelector(), + configuration.getInformerConfig().getInformerListLimit(), + configuration.getInformerConfig().getItemStore()); + } - if (informerConfig.getFieldSelector() != null - && !informerConfig.getFieldSelector().getFields().isEmpty()) { - for (var f : informerConfig.getFieldSelector().getFields()) { - if (f.negated()) { - filteredBySelectorClient = filteredBySelectorClient.withoutField(f.path(), f.value()); - } else { - filteredBySelectorClient = filteredBySelectorClient.withField(f.path(), f.value()); - } + private KubernetesClient getTargetClient() { + KubernetesClient targetClient = + controllerConfiguration.getConfigurationService().getKubernetesClient(); + if (configuration instanceof InformerEventSourceConfiguration iesc) { + var remoteClient = iesc.getKubernetesClient().orElse(null); + if (remoteClient != null) { + targetClient = remoteClient; } } - - var informer = - Optional.ofNullable(informerConfig.getInformerListLimit()) - .map(filteredBySelectorClient::withLimit) - .orElse(filteredBySelectorClient) - .runnableInformer(0); - Optional.ofNullable(informerConfig.getItemStore()).ifPresent(informer::itemStore); - var source = - new InformerWrapper<>( - informer, controllerConfiguration.getConfigurationService(), namespaceIdentifier); - source.addEventHandler(eventHandler); - sources.put(namespaceIdentifier, source); - return source; + return targetClient; } - @Override public void stop() { sources.forEach( - (ns, source) -> { + (ns, wrapper) -> { try { - log.debug("Stopping informer for namespace: {} -> {}", ns, source); - source.stop(); + var informer = + informerPool.releaseInformer( + controllerConfiguration.getName(), + configuration.getInformerConfig().getName(), + wrapper.getClassifier()); + informer.ifPresent(i -> i.removeEventHandler(eventHandler)); + + log.debug("Stopping informer for namespace: {} -> {}", ns, wrapper); } catch (Exception e) { - log.warn("Error stopping informer for namespace: {} -> {}", ns, source, e); + log.warn("Error stopping informer for namespace: {} -> {}", ns, wrapper, e); } }); sources.clear(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java index 541068aa93..596b694d7b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java @@ -18,9 +18,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Stream; @@ -28,127 +25,34 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.fabric8.kubernetes.api.model.GenericKubernetesResource; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.client.informers.ExceptionHandler; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.fabric8.kubernetes.client.informers.SharedIndexInformer; import io.fabric8.kubernetes.client.informers.cache.Cache; -import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.Status; -import io.javaoperatorsdk.operator.processing.LifecycleAware; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerClassifier; class InformerWrapper - implements LifecycleAware, IndexerResourceCache, InformerHealthIndicator { + implements IndexerResourceCache, InformerHealthIndicator { private static final Logger log = LoggerFactory.getLogger(InformerWrapper.class); private final SharedIndexInformer informer; private final Cache cache; private final String namespaceIdentifier; - private final ConfigurationService configurationService; + private final InformerClassifier informerClassifier; public InformerWrapper( SharedIndexInformer informer, - ConfigurationService configurationService, - String namespaceIdentifier) { + String namespaceIdentifier, + InformerClassifier classifier) { this.informer = informer; this.namespaceIdentifier = namespaceIdentifier; this.cache = (Cache) informer.getStore(); - this.configurationService = configurationService; - } - - @Override - public void start() throws OperatorException { - try { - - // register stopped handler if we have one defined - configurationService - .getInformerStoppedHandler() - .ifPresent( - ish -> { - final var stopped = informer.stopped(); - if (stopped != null) { - stopped.handle( - (res, ex) -> { - ish.onStop(informer, ex); - return null; - }); - } else { - final var apiTypeClass = informer.getApiTypeClass(); - final var fullResourceName = HasMetadata.getFullResourceName(apiTypeClass); - final var version = HasMetadata.getVersion(apiTypeClass); - throw new IllegalStateException( - "Cannot retrieve 'stopped' callback to listen to informer stopping for" - + " informer for " - + fullResourceName - + "/" - + version); - } - }); - if (!configurationService.stopOnInformerErrorDuringStartup()) { - informer.exceptionHandler((b, t) -> !ExceptionHandler.isDeserializationException(t)); - } - // change thread name for easier debugging - final var thread = Thread.currentThread(); - final var name = thread.getName(); - try { - thread.setName(informerInfo() + " " + thread.getId()); - final var resourceName = informer.getApiTypeClass().getSimpleName(); - log.debug( - "Starting informer for namespace: {} resource: {}", namespaceIdentifier, resourceName); - var start = informer.start(); - // note that in case we don't put here timeout and stopOnInformerErrorDuringStartup is - // false, and there is a rbac issue the get never returns; therefore operator never really - // starts - log.trace( - "Waiting informer to start namespace: {} resource: {}", - namespaceIdentifier, - resourceName); - start - .toCompletableFuture() - .get(configurationService.cacheSyncTimeout().toMillis(), TimeUnit.MILLISECONDS); - log.debug( - "Started informer for namespace: {} resource: {}", namespaceIdentifier, resourceName); - } catch (TimeoutException | ExecutionException e) { - if (configurationService.stopOnInformerErrorDuringStartup()) { - log.error("Informer startup error. Operator will be stopped. Informer: {}", informer, e); - throw new OperatorException(e); - } else { - log.warn("Informer startup error. Will periodically retry. Informer: {}", informer, e); - } - } catch (InterruptedException e) { - thread.interrupt(); - throw new IllegalStateException(e); - } finally { - // restore original name - thread.setName(name); - } - - } catch (Exception e) { - ReconcilerUtilsInternal.handleKubernetesClientException( - e, HasMetadata.getFullResourceName(informer.getApiTypeClass())); - throw new OperatorException( - "Couldn't start informer for " + versionedFullResourceName() + " resources", e); - } - } - - private String versionedFullResourceName() { - final var apiTypeClass = informer.getApiTypeClass(); - if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) { - return GenericKubernetesResource.class.getSimpleName(); - } - return ReconcilerUtilsInternal.getResourceTypeNameWithVersion(apiTypeClass); - } - - @Override - public void stop() throws OperatorException { - informer.stop(); + this.informerClassifier = classifier; } @Override @@ -201,7 +105,7 @@ public String toString() { } private String informerInfo() { - return "InformerWrapper [" + versionedFullResourceName() + "]"; + return "InformerWrapper [informerInfo" + informer.getApiTypeClass().getSimpleName() + "]"; } @Override @@ -237,4 +141,12 @@ public Status getStatus() { public String getTargetNamespace() { return namespaceIdentifier; } + + public InformerClassifier getClassifier() { + return informerClassifier; + } + + public SharedIndexInformer getInformer() { + return informer; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 8352bef665..c2ee8a5899 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -32,7 +32,6 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; @@ -51,7 +50,6 @@ import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; -@SuppressWarnings("rawtypes") public abstract class ManagedInformerEventSource< R extends HasMetadata, P extends HasMetadata, C extends Informable> extends AbstractEventSource @@ -70,13 +68,11 @@ public abstract class ManagedInformerEventSource< private final C configuration; private final Map>> indexers = new HashMap<>(); protected TemporaryResourceCache temporaryResourceCache; - protected MixedOperation client; - protected ManagedInformerEventSource(String name, MixedOperation client, C configuration) { + protected ManagedInformerEventSource(String name, C configuration) { super(configuration.getResourceClass(), name); this.comparableResourceVersions = configuration.getInformerConfig().isComparableResourceVersions(); - this.client = client; this.configuration = configuration; } @@ -159,20 +155,29 @@ protected abstract void handleEvent( Boolean deletedFinalStateUnknown, Set relatedPrimaryIDs); - @SuppressWarnings("unchecked") @Override - public synchronized void start() { + public synchronized void start(boolean dynamicRegistration) { if (isRunning()) { return; } temporaryResourceCache = new TemporaryResourceCache<>(comparableResourceVersions, this); - this.cache = new InformerManager<>(client, configuration, this); + this.cache = new InformerManager<>(configuration, this); cache.setControllerConfiguration(controllerConfiguration); cache.addIndexers(indexers); + // A dynamically registered event source may join an already-running shared informer whose cache + // is already populated. Those pre-existing resources are still delivered to this newly added + // handler: the underlying Fabric8 informer replays the current cache contents to every handler + // at registration time (see SharedProcessor#addProcessorListener). Replaying them here as well + // would deliver every pre-existing resource twice. manager().start(); super.start(); } + @Override + public void start() throws OperatorException { + start(false); + } + @Override public synchronized void stop() { if (!isRunning()) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java new file mode 100644 index 0000000000..e61496161c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java @@ -0,0 +1,199 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.informers.ExceptionHandler; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACES; + +public abstract class AbstractInformerPool implements InformerPool { + + private static final Logger log = LoggerFactory.getLogger(AbstractInformerPool.class); + + protected ConfigurationService configurationService; + + public ConfigurationService getConfigurationService() { + return configurationService; + } + + public void setConfigurationService(ConfigurationService configurationService) { + this.configurationService = configurationService; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + protected SharedIndexInformer createInformer( + InformerClassifier classifier, KubernetesClient client) { + + MixedOperation clientWithResource; + if (classifier.groupVersionKind() != null) { + clientWithResource = + client.genericKubernetesResources( + classifier.groupVersionKind().getApiVersion(), + classifier.groupVersionKind().getKind()); + } else { + clientWithResource = client.resources(classifier.resourceClass()); + } + + FilterWatchListDeletable filteredClient; + if (WATCH_ALL_NAMESPACES.equals(classifier.namespaceIdentifier())) { + filteredClient = + clientWithResource + .inAnyNamespace() + .withLabelSelector(classifier.labelSelector()) + .withShardSelector(classifier.shardSelector()); + } else { + filteredClient = + clientWithResource + .inNamespace(classifier.namespaceIdentifier()) + .withLabelSelector(classifier.labelSelector()) + .withShardSelector(classifier.shardSelector()); + } + + if (classifier.labelSelector() != null) { + filteredClient = + (FilterWatchListDeletable) filteredClient.withLabelSelector(classifier.labelSelector()); + } + if (classifier.shardSelector() != null) { + filteredClient = + (FilterWatchListDeletable) filteredClient.withShardSelector(classifier.shardSelector()); + } + + if (classifier.fieldSelector() != null && !classifier.fieldSelector().getFields().isEmpty()) { + for (var f : classifier.fieldSelector().getFields()) { + if (f.negated()) { + filteredClient = + (FilterWatchListDeletable) filteredClient.withoutField(f.path(), f.value()); + } else { + filteredClient = (FilterWatchListDeletable) filteredClient.withField(f.path(), f.value()); + } + } + } + + if (classifier.informerListLimit() != null) { + filteredClient = + (FilterWatchListDeletable) filteredClient.withLimit(classifier.informerListLimit()); + } + + var informer = filteredClient.runnableInformer(0); + + Optional.ofNullable(classifier.itemStore()).ifPresent(informer::itemStore); + + configurationService + .getInformerStoppedHandler() + .ifPresent( + ish -> { + final var stopped = informer.stopped(); + if (stopped != null) { + stopped.handle( + (res, ex) -> { + ish.onStop(informer, (Throwable) ex); + return null; + }); + } else { + final var apiTypeClass = informer.getApiTypeClass(); + final var fullResourceName = HasMetadata.getFullResourceName(apiTypeClass); + final var version = HasMetadata.getVersion(apiTypeClass); + throw new IllegalStateException( + "Cannot retrieve 'stopped' callback to listen to informer stopping for" + + " informer for " + + fullResourceName + + "/" + + version); + } + }); + if (!configurationService.stopOnInformerErrorDuringStartup()) { + informer.exceptionHandler((b, t) -> !ExceptionHandler.isDeserializationException(t)); + } + return informer; + } + + @Override + public void start( + SharedIndexInformer informer, InformerClassifier informerClassifier) { + // change thread name for easier debugging + final var thread = Thread.currentThread(); + final var name = thread.getName(); + try { + thread.setName( + "InformerInfo[informerInfo" + + informer.getApiTypeClass().getSimpleName() + + "]" + + " " + + thread.getId()); + final var resourceName = informer.getApiTypeClass().getSimpleName(); + // idempotent: if the informer was already started (e.g. by the pool when it was + // created/reused), this just returns the existing start future without restarting it + var start = informer.start(); + // note that in case we don't put here timeout and stopOnInformerErrorDuringStartup is + // false, and there is a rbac issue the get never returns; therefore operator never really + // starts + log.trace( + "Waiting informer to start namespace: {} resource: {}", + informerClassifier.namespaceIdentifier(), + resourceName); + start + .toCompletableFuture() + .get(configurationService.cacheSyncTimeout().toMillis(), TimeUnit.MILLISECONDS); + log.debug( + "Started informer for namespace: {} resource: {}", + informerClassifier.namespaceIdentifier(), + resourceName); + } catch (TimeoutException | ExecutionException e) { + if (configurationService.stopOnInformerErrorDuringStartup()) { + log.error("Informer startup error. Operator will be stopped. Informer: {}", informer, e); + throw new OperatorException(e); + } else { + log.warn("Informer startup error. Will periodically retry. Informer: {}", informer, e); + } + } catch (InterruptedException e) { + thread.interrupt(); + throw new IllegalStateException(e); + } catch (Exception e) { + ReconcilerUtilsInternal.handleKubernetesClientException( + e, HasMetadata.getFullResourceName(informer.getApiTypeClass())); + throw new OperatorException( + "Couldn't start informer for " + versionedFullResourceName(informer) + " resources", e); + } finally { + // restore original name + thread.setName(name); + } + } + + private String versionedFullResourceName(SharedIndexInformer informer) { + final var apiTypeClass = informer.getApiTypeClass(); + if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) { + return GenericKubernetesResource.class.getSimpleName(); + } + return ReconcilerUtilsInternal.getResourceTypeNameWithVersion(apiTypeClass); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java new file mode 100644 index 0000000000..40b28ea04d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java @@ -0,0 +1,78 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; + +@SuppressWarnings({"unchecked", "rawtypes"}) +public class AlwaysNewInformerPool extends AbstractInformerPool { + + private static final Logger log = LoggerFactory.getLogger(AlwaysNewInformerPool.class); + + private final Map informers = new ConcurrentHashMap(); + + @Override + public SharedIndexInformer getInformer( + String controllerName, + String name, + InformerClassifier classifier, + KubernetesClient client) { + var informer = createInformer(classifier, client); + informers.put(new ClassifierWithName(controllerName, name, classifier), informer); + return informer; + } + + @Override + public Optional> releaseInformer( + String controllerName, String name, InformerClassifier classifier) { + var informer = informers.remove(new ClassifierWithName(controllerName, name, classifier)); + if (informer != null) { + informer.stop(); + } else { + log.warn("Informer was not found for classifier: {}", classifier); + } + return Optional.ofNullable(informer); + } + + /** Number of informers currently tracked (i.e. created but not yet released). */ + public int size() { + return informers.size(); + } + + /** + * Number of distinct informers currently held for the given resource type. Since this pool never + * shares informers, this equals the number of registered users (controller + event source name) + * watching that resource type. + */ + @Override + public long numberOfInformersForResource(Class resourceClass) { + return informers.keySet().stream() + .filter(key -> resourceClass.equals(key.classifier().resourceClass())) + .count(); + } + + public record ClassifierWithName( + String controllerName, String name, InformerClassifier classifier) {} +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java new file mode 100644 index 0000000000..63c5bc1fc2 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java @@ -0,0 +1,141 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; + +public class DefaultInformerPool extends AbstractInformerPool { + + private static final Logger log = LoggerFactory.getLogger(DefaultInformerPool.class); + + private final Map, SharedIndexInformer> informers = new HashMap<>(); + private final Map, AtomicInteger> counters = new HashMap<>(); + + public DefaultInformerPool(ConfigurationService service) { + setConfigurationService(service); + } + + @SuppressWarnings("unchecked") + @Override + public SharedIndexInformer getInformer( + String controllerName, + String name, + InformerClassifier classifier, + KubernetesClient client) { + SharedIndexInformer informer; + synchronized (this) { + informer = (SharedIndexInformer) informers.get(classifier); + if (informer == null) { + informer = createInformer(classifier, client); + informers.put(classifier, informer); + counters.put(classifier, new AtomicInteger(1)); + log.debug( + "Created new pooled informer for classifier: {}. Requested by controller: {}, event" + + " source: {}", + classifier, + controllerName, + name); + } else { + informers.keySet().stream() + .filter(existing -> existing.differsOnlyByInformerListLimit(classifier)) + .findFirst() + .ifPresent( + existing -> + log.warn( + "Reusing informer for classifier {} that differs only by informerListLimit" + + " (existing: {}, requested: {}). The existing informerListLimit is" + + " kept.", + classifier, + existing.informerListLimit(), + classifier.informerListLimit())); + var referenceCount = counters.get(classifier).incrementAndGet(); + log.debug( + "Reusing pooled informer for classifier: {}. Reference count now: {}. Requested by" + + " controller: {}, event source: {}", + classifier, + referenceCount, + controllerName, + name); + } + } + return informer; + } + + @SuppressWarnings("unchecked") + @Override + public synchronized Optional> releaseInformer( + String controllerName, String name, InformerClassifier classifier) { + var counter = counters.get(classifier); + var informer = (SharedIndexInformer) informers.get(classifier); + if (counter == null || informer == null) { + log.warn("No informer found in the pool for classifier: {}", classifier); + return Optional.empty(); + } + // Only the last controller sharing the informer stops it; the informer is still returned to the + // caller in every case so it can remove its own event handler from the (possibly still running) + // shared informer. + var referenceCount = counter.decrementAndGet(); + if (referenceCount == 0) { + counters.remove(classifier); + informers.remove(classifier); + informer.stop(); + log.debug( + "Released and stopped last-referenced pooled informer for classifier: {}. Released by" + + " controller: {}, event source: {}", + classifier, + controllerName, + name); + } else { + log.debug( + "Released pooled informer for classifier: {}, kept running. Reference count now: {}." + + " Released by controller: {}, event source: {}", + classifier, + referenceCount, + controllerName, + name); + } + return Optional.of(informer); + } + + /** Total number of distinct informers currently held in the pool. */ + public synchronized int size() { + return informers.size(); + } + + /** + * Number of distinct informers currently held in the pool for the given resource type. When + * multiple controllers share a single informer for a resource, this returns {@code 1} for that + * resource type regardless of how many controllers use it. + */ + @Override + public synchronized long numberOfInformersForResource( + Class resourceClass) { + return informers.keySet().stream() + .filter(classifier -> resourceClass.equals(classifier.resourceClass())) + .count(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifier.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifier.java new file mode 100644 index 0000000000..92c265f1a7 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifier.java @@ -0,0 +1,78 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.informers.cache.ItemStore; +import io.javaoperatorsdk.operator.api.config.informer.FieldSelector; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; + +/** + * Indexers are not part of classifier since those can be added dynamically to an informer, also can + * live side by side from different controllers, just users have to avoid naming collision. + */ +public record InformerClassifier( + String apiServerUrl, + String labelSelector, + String shardSelector, + String namespaceIdentifier, + Class resourceClass, + GroupVersionKind groupVersionKind, + FieldSelector fieldSelector, + Long informerListLimit, + ItemStore itemStore) { + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof InformerClassifier that)) { + return false; + } + return Objects.equals(apiServerUrl, that.apiServerUrl) + && Objects.equals(labelSelector, that.labelSelector) + && Objects.equals(shardSelector, that.shardSelector) + && Objects.equals(namespaceIdentifier, that.namespaceIdentifier) + && Objects.equals(resourceClass, that.resourceClass) + && Objects.equals(groupVersionKind, that.groupVersionKind) + && Objects.equals(fieldSelector, that.fieldSelector) + && Objects.equals(itemStore, that.itemStore); + } + + @Override + public int hashCode() { + return Objects.hash( + apiServerUrl, + labelSelector, + shardSelector, + namespaceIdentifier, + resourceClass, + groupVersionKind, + fieldSelector, + itemStore); + } + + /** + * Checks whether this classifier and the other are equal in every attribute except for the {@link + * #informerListLimit()}, which differs between them. + */ + public boolean differsOnlyByInformerListLimit(InformerClassifier other) { + return equals(other) && !Objects.equals(informerListLimit, other.informerListLimit); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java new file mode 100644 index 0000000000..c6efee2026 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java @@ -0,0 +1,58 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; + +@Experimental( + "This is experimental only in the sense that the API could be improved in a" + + " non-backwards-compatible way. The feature we provide otherwise is prod ready.") +public interface InformerPool { + + SharedIndexInformer getInformer( + String controllerName, + String name, + InformerClassifier classifier, + KubernetesClient client); + + /** + * Starts the informer (if not already started) and blocks until its cache has synced, or the + * configured {@link ConfigurationService#cacheSyncTimeout()} elapses. Callers are expected to + * invoke this after {@link #getInformer(String,String, InformerClassifier, KubernetesClient)} + * returns; the pool itself only registers/reference-counts the informer and does not block on + * cache sync internally. + */ + void start( + SharedIndexInformer informer, InformerClassifier classifier); + + Optional> releaseInformer( + String controllerName, String name, InformerClassifier classifier); + + /** + * Number of distinct informers currently held in the pool for the given resource type. With a + * sharing pool multiple controllers watching the same resource are backed by a single informer + * (so this returns {@code 1}), whereas a non-sharing pool creates one informer per user. + */ + long numberOfInformersForResource(Class resourceClass); + + void setConfigurationService(ConfigurationService configurationService); +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java index 61b434c0c4..3e5b872ba2 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java @@ -26,12 +26,12 @@ import io.fabric8.kubernetes.api.model.authorization.v1.ResourceRule; import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectRulesReview; import io.fabric8.kubernetes.api.model.authorization.v1.SubjectRulesReviewStatus; +import io.fabric8.kubernetes.client.Config; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.V1ApiextensionAPIGroupDSL; import io.fabric8.kubernetes.client.dsl.AnyNamespaceOperation; import io.fabric8.kubernetes.client.dsl.ApiextensionsAPIGroupDSL; import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable; -import io.fabric8.kubernetes.client.dsl.Informable; import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.dsl.NamespaceableResource; import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; @@ -112,9 +112,9 @@ public static KubernetesClient client( when(filterable.runnableInformer(anyLong())).thenReturn(informer); - Informable informable = mock(Informable.class); - when(filterable.withLimit(anyLong())).thenReturn(informable); - when(informable.runnableInformer(anyLong())).thenReturn(informer); + // The informer pool casts the result of withLimit() back to FilterWatchListDeletable, so it has + // to return the filterable mock (which is one) rather than a plain Informable mock. + when(filterable.withLimit(anyLong())).thenReturn(filterable); when(client.resources(clazz)).thenReturn(resources); when(client.leaderElector()) @@ -138,6 +138,10 @@ public static KubernetesClient client( final var serialization = new KubernetesSerialization(); when(client.getKubernetesSerialization()).thenReturn(serialization); + final var config = mock(Config.class); + when(config.getMasterUrl()).thenReturn("https://localhost:8443/"); + when(client.getConfiguration()).thenReturn(config); + return client; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java index 91d60f7aa7..b725f49132 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java @@ -62,6 +62,9 @@ class ControllerTest { @Test void crdShouldNotBeCheckedForNativeResources() { final var client = MockKubernetesClient.client(Secret.class); + final var configurationService = + ConfigurationService.newOverriddenConfigurationService( + this.configurationService, o -> o.withKubernetesClient(client)); final var configuration = MockControllerConfiguration.forResource(Secret.class, configurationService); final var controller = new Controller(reconciler, configuration, client); @@ -75,7 +78,8 @@ void notifiesMetricsWhenEventProcessorStarts() { final var metrics = mock(Metrics.class); final var configurationService = ConfigurationService.newOverriddenConfigurationService( - new BaseConfigurationService(), o -> o.withMetrics(metrics)); + new BaseConfigurationService(), + o -> o.withMetrics(metrics).withKubernetesClient(client)); final var configuration = MockControllerConfiguration.forResource(Secret.class, configurationService); final var controller = new Controller(reconciler, configuration, client); @@ -95,7 +99,8 @@ void doesNotNotifyMetricsWhenEventProcessorNotStarted() { final var metrics = mock(Metrics.class); final var configurationService = ConfigurationService.newOverriddenConfigurationService( - new BaseConfigurationService(), o -> o.withMetrics(metrics)); + new BaseConfigurationService(), + o -> o.withMetrics(metrics).withKubernetesClient(client)); final var configuration = MockControllerConfiguration.forResource(Secret.class, configurationService); final var controller = new Controller(reconciler, configuration, client); @@ -110,7 +115,8 @@ void crdShouldNotBeCheckedForCustomResourcesIfDisabled() { final var client = MockKubernetesClient.client(TestCustomResource.class); ConfigurationService configurationService = ConfigurationService.newOverriddenConfigurationService( - new BaseConfigurationService(), o -> o.checkingCRDAndValidateLocalModel(false)); + new BaseConfigurationService(), + o -> o.checkingCRDAndValidateLocalModel(false).withKubernetesClient(client)); final var configuration = MockControllerConfiguration.forResource(TestCustomResource.class, configurationService); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java index 251a0e47ae..ec39b30409 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java @@ -24,6 +24,7 @@ import io.javaoperatorsdk.operator.MockKubernetesClient; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.MockControllerConfiguration; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; @@ -95,8 +96,8 @@ public void startCascadesToEventSources() { eventSourceManager.start(); - verify(eventSource, times(1)).start(); - verify(eventSource2, times(1)).start(); + verify(eventSource, times(1)).start(false); + verify(eventSource2, times(1)).start(false); } @Test @@ -202,12 +203,14 @@ void changesNamespacesOnControllerAndInformerEventSources() { private EventSourceManager initManager() { final var configuration = MockControllerConfiguration.forResource(ConfigMap.class); - final var configService = new BaseConfigurationService(); + final var mockClient = MockKubernetesClient.client(ConfigMap.class); + final var configService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + overrider -> overrider.withKubernetesClient(mockClient)); when(configuration.getConfigurationService()).thenReturn(configService); - final Controller controller = - new Controller( - mock(Reconciler.class), configuration, MockKubernetesClient.client(ConfigMap.class)); + final Controller controller = new Controller(mock(Reconciler.class), configuration, mockClient); return new EventSourceManager(controller); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java index ac24375242..191836a0dc 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java @@ -122,6 +122,21 @@ private ReconciliationDispatcher init( boolean useFinalizer) { final Class resourceClass = (Class) customResource.getClass(); + final var kubernetesClient = MockKubernetesClient.client(resourceClass); + // The informer pool obtains its client from the ConfigurationService, so the mock client has to + // be set there as well (not only on the Controller); otherwise starting the informers would hit + // a real cluster. Cloner and SSA settings are re-supplied so the re-wrapping does not drop + // them. + configurationService = + ConfigurationService.newOverriddenConfigurationService( + configurationService, + overrider -> + overrider + .withKubernetesClient(kubernetesClient) + .withResourceCloner(configurationService.getResourceCloner()) + .withUseSSAToPatchPrimaryResource( + configurationService.useSSAToPatchPrimaryResource())); + configuration = configuration == null ? MockControllerConfiguration.forResource(resourceClass, configurationService) @@ -139,7 +154,7 @@ private ReconciliationDispatcher init( .thenReturn(Optional.of(Duration.ofHours(RECONCILIATION_MAX_INTERVAL))); Controller controller = - new Controller<>(reconciler, configuration, MockKubernetesClient.client(resourceClass)) { + new Controller<>(reconciler, configuration, kubernetesClient) { @Override public boolean useFinalizer() { return useFinalizer; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java index 58284c9a8d..c885a887d9 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java @@ -65,7 +65,7 @@ public void setUpSource( source.setEventHandler(eventHandler); if (start) { - source.start(); + source.start(false); } } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index 38190a96dc..185b626161 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -27,6 +27,7 @@ import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.TestUtils; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.ResolvedControllerConfiguration; import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; @@ -59,7 +60,11 @@ class ControllerEventSourceTest @BeforeEach public void setup() { - when(controllerConfig.getConfigurationService()).thenReturn(new BaseConfigurationService()); + var clientMock = MockKubernetesClient.client(TestCustomResource.class); + when(controllerConfig.getConfigurationService()) + .thenReturn( + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), o -> o.withKubernetesClient(clientMock))); var ic = mock(InformerConfiguration.class); when(controllerConfig.getInformerConfig()).thenReturn(ic); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 210ce52fcc..01ee25e44c 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -94,6 +94,10 @@ void setup() { when(informerEventSourceConfiguration.getResourceClass()).thenReturn(Deployment.class); when(informerConfig.isComparableResourceVersions()).thenReturn(true); when(informerConfig.getEffectiveNamespaces(any())).thenReturn(DEFAULT_NAMESPACES_SET); + // a plain Long-returning Mockito mock yields 0 here, but a real unconfigured informer has no + // list limit; without this the pool would take the withLimit(...) branch when creating the + // informer + when(informerConfig.getInformerListLimit()).thenReturn(null); informerEventSource = buildInformerEventSource(); } @@ -101,7 +105,7 @@ void setup() { private InformerEventSource buildInformerEventSource() { InformerEventSource eventSource = spy( - new InformerEventSource<>(informerEventSourceConfiguration, clientMock) { + new InformerEventSource<>(informerEventSourceConfiguration) { // mocking start @Override public synchronized void start() {} @@ -259,22 +263,25 @@ void ownUpdateEventIsDeferredDuringActiveFilter() { void informerStoppedHandlerShouldBeCalledWhenInformerStops() { final var exception = new RuntimeException("Informer stopped exceptionally!"); final var informerStoppedHandler = mock(InformerStoppedHandler.class); + // the informer is created by the pool, which uses the client from the configuration service, so + // the mock client has to be set there for its informer-start behavior to take effect + final var mockClient = + MockKubernetesClient.client( + Deployment.class, + unused -> { + throw exception; + }); var configuration = ConfigurationService.newOverriddenConfigurationService( new BaseConfigurationService(), - o -> o.withInformerStoppedHandler(informerStoppedHandler)); + o -> + o.withInformerStoppedHandler(informerStoppedHandler) + .withKubernetesClient(mockClient)); var mockControllerConfig = mock(ControllerConfiguration.class); when(mockControllerConfig.getConfigurationService()).thenReturn(configuration); - informerEventSource = - new InformerEventSource<>( - informerEventSourceConfiguration, - MockKubernetesClient.client( - Deployment.class, - unused -> { - throw exception; - })); + informerEventSource = new InformerEventSource<>(informerEventSourceConfiguration); informerEventSource.setControllerConfiguration(mockControllerConfig); // by default informer fails to start if there is an exception in the client on start. diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java new file mode 100644 index 0000000000..32555aea7a --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java @@ -0,0 +1,129 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link AlwaysNewInformerPool}: unlike {@link DefaultInformerPool} it never shares + * informers. A distinct informer is created for every {@code controllerName}+{@code name}+{@code + * classifier} combination, and release is scoped to that same combination. + */ +class AlwaysNewInformerPoolTest { + + private static final String CONTROLLER = "controller"; + private static final String ES_NAME = "event-source"; + + private final KubernetesClient client = MockKubernetesClient.client(TestCustomResource.class); + private final AlwaysNewInformerPool pool = new AlwaysNewInformerPool(); + + @BeforeEach + void setUp() { + pool.setConfigurationService(new BaseConfigurationService()); + } + + @Test + void createsSeparateInformerForSameClassifierFromDifferentControllers() { + var classifier = classifier("default"); + + pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + pool.getInformer("other-controller", ES_NAME, classifier, client); + + // no sharing: one informer created per user even for an identical classifier + assertThat(pool.size()).isEqualTo(2); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void createsSeparateInformerForDifferentEventSourceNames() { + var classifier = classifier("default"); + + pool.getInformer(CONTROLLER, "event-source-1", classifier, client); + pool.getInformer(CONTROLLER, "event-source-2", classifier, client); + + assertThat(pool.size()).isEqualTo(2); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void createsNewInformerEvenForAnIdenticalKey() { + var classifier = classifier("default"); + + pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + + // the same key overwrites the map entry, but a fresh informer is created on every call + assertThat(pool.size()).isEqualTo(1); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void releaseStopsAndRemovesTheInformer() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + + assertThat(released).containsSame(informer); + verify(informer, times(1)).stop(); + assertThat(pool.size()).isZero(); + } + + @Test + void releaseIsScopedToControllerAndName() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + + // same classifier but a different controller: nothing is released or stopped + var released = pool.releaseInformer("other-controller", ES_NAME, classifier); + + assertThat(released).isEmpty(); + verify(informer, never()).stop(); + assertThat(pool.size()).isEqualTo(1); + } + + @Test + void releaseOfUnknownInformerReturnsEmptyAndDoesNotThrow() { + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier("never-registered")); + + assertThat(released).isEmpty(); + assertThat(pool.size()).isZero(); + } + + private InformerClassifier classifier(String namespace) { + return new InformerClassifier<>( + client.getConfiguration().getMasterUrl(), + null, + null, + namespace, + TestCustomResource.class, + null, + null, + null, + null); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java new file mode 100644 index 0000000000..c417f3b31d --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java @@ -0,0 +1,128 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for the reference-counting / sharing behavior of {@link DefaultInformerPool}: a single + * informer is created and shared per classifier, and it is stopped only when the last user releases + * it. + */ +class DefaultInformerPoolTest { + + private static final String CONTROLLER = "controller"; + private static final String ES_NAME = "event-source"; + + private final KubernetesClient client = MockKubernetesClient.client(TestCustomResource.class); + private final DefaultInformerPool pool = new DefaultInformerPool(new BaseConfigurationService()); + + @Test + void sharesSingleInformerForTheSameClassifier() { + var classifier = classifier("default"); + + var first = pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + var second = pool.getInformer("other-controller", "other-es", classifier, client); + + assertThat(first).isSameAs(second); + assertThat(pool.size()).isEqualTo(1); + assertThat(pool.numberOfInformersForResource(TestCustomResource.class)).isEqualTo(1); + // the underlying informer must be created exactly once, not once per user + verify(client, times(1)).resources(TestCustomResource.class); + } + + @Test + void createsSeparateInformersForDifferentClassifiers() { + pool.getInformer(CONTROLLER, ES_NAME, classifier("ns1"), client); + pool.getInformer(CONTROLLER, ES_NAME, classifier("ns2"), client); + + assertThat(pool.size()).isEqualTo(2); + assertThat(pool.numberOfInformersForResource(TestCustomResource.class)).isEqualTo(2); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void sharesInformerWhenClassifiersDifferOnlyByListLimit() { + var withLimit100 = + new InformerClassifier<>( + masterUrl(), null, null, "default", TestCustomResource.class, null, null, 100L, null); + var withLimit200 = + new InformerClassifier<>( + masterUrl(), null, null, "default", TestCustomResource.class, null, null, 200L, null); + + var first = pool.getInformer(CONTROLLER, ES_NAME, withLimit100, client); + var second = pool.getInformer("other-controller", "other-es", withLimit200, client); + + assertThat(first).isSameAs(second); + assertThat(pool.size()).isEqualTo(1); + verify(client, times(1)).resources(TestCustomResource.class); + } + + @Test + void doesNotStopSharedInformerUntilLastRelease() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + pool.getInformer("other-controller", "other-es", classifier, client); + + pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + verify(informer, never()).stop(); + assertThat(pool.size()).isEqualTo(1); + + pool.releaseInformer("other-controller", "other-es", classifier); + verify(informer, times(1)).stop(); + assertThat(pool.size()).isZero(); + } + + @Test + void releaseReturnsInformerEvenWhileStillShared() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + pool.getInformer("other-controller", "other-es", classifier, client); + + // the caller needs the (still-running) informer back so it can remove its own event handler + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + + assertThat(released).containsSame(informer); + verify(informer, never()).stop(); + } + + @Test + void releaseOfUnknownClassifierReturnsEmptyAndDoesNotThrow() { + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier("never-registered")); + + assertThat(released).isEmpty(); + assertThat(pool.size()).isZero(); + } + + private String masterUrl() { + return client.getConfiguration().getMasterUrl(); + } + + private InformerClassifier classifier(String namespace) { + return new InformerClassifier<>( + masterUrl(), null, null, namespace, TestCustomResource.class, null, null, null, null); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifierTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifierTest.java new file mode 100644 index 0000000000..c251a31f3f --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifierTest.java @@ -0,0 +1,265 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.informers.cache.ItemStore; +import io.javaoperatorsdk.operator.api.config.informer.FieldSelector; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResourceOtherV1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for the identity semantics of {@link InformerClassifier}. The classifier is used as + * the key that decides whether two controllers share a single pooled informer, so its equality + * contract (in particular that {@code informerListLimit} is intentionally excluded) is + * load-bearing. + */ +class InformerClassifierTest { + + private static final String URL = "https://localhost:8443/"; + private static final String LABEL = "app=foo"; + private static final String SHARD = "shard-1"; + private static final String NAMESPACE = "default"; + private static final GroupVersionKind GVK = new GroupVersionKind("sample.io/v1", "Foo"); + private static final FieldSelector FIELD_SELECTOR = + new FieldSelector(new FieldSelector.Field("status.phase", "Running")); + private static final Long LIMIT = 100L; + private static final ItemStore ITEM_STORE = mock(ItemStore.class); + + private static InformerClassifier base() { + return new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE); + } + + @Test + void classifiersWithIdenticalFieldsAreEqual() { + assertThat(base()).isEqualTo(base()); + assertThat(base()).hasSameHashCodeAs(base()); + } + + @Test + void informerListLimitIsExcludedFromEqualityAndHashCode() { + var withOtherLimit = + new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + 999L, + ITEM_STORE); + + assertThat(base()).isEqualTo(withOtherLimit); + assertThat(base()).hasSameHashCodeAs(withOtherLimit); + } + + @Test + void differsWhenApiServerUrlDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + "https://other:8443/", + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenLabelSelectorDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + URL, + "app=bar", + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenShardSelectorDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + URL, + LABEL, + "shard-2", + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenNamespaceDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + URL, + LABEL, + SHARD, + "other-ns", + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenResourceClassDiffers() { + // item stores are null here because their generic type is tied to the resource class, which is + // exactly the field under test; this keeps the resource class the only difference. + var forTestResource = + new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + null); + var forOtherResource = + new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResourceOtherV1.class, + GVK, + FIELD_SELECTOR, + LIMIT, + null); + + assertThat(forTestResource).isNotEqualTo(forOtherResource); + } + + @Test + void differsWhenGroupVersionKindDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + new GroupVersionKind("sample.io/v1", "Bar"), + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenFieldSelectorDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + new FieldSelector(new FieldSelector.Field("status.phase", "Pending")), + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenItemStoreDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + mock(ItemStore.class))); + } + + @Test + void differsOnlyByInformerListLimitIsTrueWhenOnlyLimitDiffers() { + var withOtherLimit = + new InformerClassifier<>( + URL, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + 999L, + ITEM_STORE); + + assertThat(base().differsOnlyByInformerListLimit(withOtherLimit)).isTrue(); + } + + @Test + void differsOnlyByInformerListLimitIsFalseWhenFullyEqual() { + assertThat(base().differsOnlyByInformerListLimit(base())).isFalse(); + } + + @Test + void differsOnlyByInformerListLimitIsFalseWhenAnotherFieldDiffers() { + // different namespace AND different limit: not "only by limit" + var differentNamespaceAndLimit = + new InformerClassifier<>( + URL, + LABEL, + SHARD, + "other-ns", + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + 999L, + ITEM_STORE); + + assertThat(base().differsOnlyByInformerListLimit(differentNamespaceAndLimit)).isFalse(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AbstractSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AbstractSharedInformerIT.java new file mode 100644 index 0000000000..ff50ea7f43 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AbstractSharedInformerIT.java @@ -0,0 +1,105 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Registers two controllers, each backed by its own primary custom resource, that both watch {@link + * ConfigMap} as a secondary resource using an identical {@code InformerEventSource} configuration. + * + *

The functional behavior (both controllers reconcile) is identical regardless of the informer + * pool strategy; only the number of underlying {@code ConfigMap} informers differs, which is why + * the expected count is left abstract. Concrete subclasses pick the pool strategy via {@link + * #configurationServiceOverrider()}. + */ +public abstract class AbstractSharedInformerIT { + + public static final String TEST_RESOURCE_1 = "test1"; + public static final String TEST_RESOURCE_2 = "test2"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new SharedInformerReconciler1()) + .withReconciler(new SharedInformerReconciler2()) + .withConfigurationService(configurationServiceOverrider()) + .build(); + + /** The informer pool strategy under test. */ + protected abstract Consumer configurationServiceOverrider(); + + /** + * Expected number of {@code ConfigMap} informers: {@code 1} when the two controllers share a + * single informer, {@code 2} when each controller gets its own. + */ + protected abstract long expectedConfigMapInformerCount(); + + @Test + void bothControllersReconcileWatchingConfigMap() { + extension.create(customResource1(TEST_RESOURCE_1)); + extension.create(customResource2(TEST_RESOURCE_2)); + + // both controllers reconcile, which guarantees their event sources (and thus their informers) + // have been started + await() + .untilAsserted( + () -> { + assertThat( + extension + .getReconcilerOfType(SharedInformerReconciler1.class) + .getNumberOfExecutions()) + .isPositive(); + assertThat( + extension + .getReconcilerOfType(SharedInformerReconciler2.class) + .getNumberOfExecutions()) + .isPositive(); + }); + + var pool = extension.getOperator().getConfigurationService().informerPool(); + + // the ConfigMap informer count depends on the pool strategy (shared vs. one-per-controller) + assertThat(pool.numberOfInformersForResource(ConfigMap.class)) + .isEqualTo(expectedConfigMapInformerCount()); + // the two distinct primary resources are always backed by their own informers + assertThat(pool.numberOfInformersForResource(SharedInformerCustomResource1.class)).isEqualTo(1); + assertThat(pool.numberOfInformersForResource(SharedInformerCustomResource2.class)).isEqualTo(1); + } + + SharedInformerCustomResource1 customResource1(String name) { + var res = new SharedInformerCustomResource1(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return res; + } + + SharedInformerCustomResource2 customResource2(String name) { + var res = new SharedInformerCustomResource2(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AlwaysNewInformerPoolSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AlwaysNewInformerPoolSharedInformerIT.java new file mode 100644 index 0000000000..a3ecd7c5e4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AlwaysNewInformerPoolSharedInformerIT.java @@ -0,0 +1,39 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import java.util.function.Consumer; + +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.AlwaysNewInformerPool; + +/** + * Runs {@link AbstractSharedInformerIT} with the {@link AlwaysNewInformerPool}: informers are never + * shared, so each of the two controllers watching {@code ConfigMap} gets its own informer. + */ +public class AlwaysNewInformerPoolSharedInformerIT extends AbstractSharedInformerIT { + + @Override + protected Consumer configurationServiceOverrider() { + return overrider -> overrider.withInformerPool(new AlwaysNewInformerPool()); + } + + @Override + protected long expectedConfigMapInformerCount() { + // no sharing: one ConfigMap informer per controller + return 2; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource1.java new file mode 100644 index 0000000000..af73cb15ff --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource1.java @@ -0,0 +1,28 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("si1") +public class SharedInformerCustomResource1 extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource2.java new file mode 100644 index 0000000000..e4dbf667c2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerCustomResource2.java @@ -0,0 +1,28 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("si2") +public class SharedInformerCustomResource2 extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerIT.java new file mode 100644 index 0000000000..6e51bcb44a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerIT.java @@ -0,0 +1,38 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import java.util.function.Consumer; + +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; + +/** + * Runs {@link AbstractSharedInformerIT} with the default (sharing) informer pool, so both + * controllers watching {@code ConfigMap} are backed by a single shared informer. + */ +public class SharedInformerIT extends AbstractSharedInformerIT { + + @Override + protected Consumer configurationServiceOverrider() { + // no override: use the default DefaultInformerPool + return overrider -> {}; + } + + @Override + protected long expectedConfigMapInformerCount() { + return 1; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler1.java new file mode 100644 index 0000000000..d526ff7713 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler1.java @@ -0,0 +1,63 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Watches {@link ConfigMap} as a secondary resource. Together with {@link + * SharedInformerReconciler2}, which watches the same secondary resource type with an identical + * configuration, this is used to verify that both controllers share a single underlying informer + * from the informer pool. + */ +@ControllerConfiguration +public class SharedInformerReconciler1 implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var config = + InformerEventSourceConfiguration.from(ConfigMap.class, SharedInformerCustomResource1.class) + .build(); + return List.of(new InformerEventSource<>(config, context)); + } + + @Override + public UpdateControl reconcile( + SharedInformerCustomResource1 resource, Context context) { + numberOfExecutions.incrementAndGet(); + resource.setStatus(new SharedInformerStatus()); + resource.getStatus().setReconciledBy(getClass().getSimpleName()); + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler2.java new file mode 100644 index 0000000000..84f9718d52 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerReconciler2.java @@ -0,0 +1,62 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Watches {@link ConfigMap} as a secondary resource with the same configuration as {@link + * SharedInformerReconciler1} so that both controllers share a single underlying informer from the + * informer pool. + */ +@ControllerConfiguration +public class SharedInformerReconciler2 implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var config = + InformerEventSourceConfiguration.from(ConfigMap.class, SharedInformerCustomResource2.class) + .build(); + return List.of(new InformerEventSource<>(config, context)); + } + + @Override + public UpdateControl reconcile( + SharedInformerCustomResource2 resource, Context context) { + numberOfExecutions.incrementAndGet(); + resource.setStatus(new SharedInformerStatus()); + resource.getStatus().setReconciledBy(getClass().getSimpleName()); + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerStatus.java new file mode 100644 index 0000000000..446fee6e8b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/SharedInformerStatus.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +public class SharedInformerStatus { + + private String reconciledBy; + + public String getReconciledBy() { + return reconciledBy; + } + + public void setReconciledBy(String reconciledBy) { + this.reconciledBy = reconciledBy; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AbstractDeregisterSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AbstractDeregisterSharedInformerIT.java new file mode 100644 index 0000000000..2ca7b864f8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AbstractDeregisterSharedInformerIT.java @@ -0,0 +1,97 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.deregister; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Verifies the lifecycle of a dynamically registered event source in the informer pool: registering + * it creates a (single) informer for the watched resource, and de-registering it releases that + * informer so the pool no longer holds one for that resource type. + * + *

A single controller registers a single event source, so no informer sharing is involved: the + * behavior and assertions are identical for every pool strategy. Concrete subclasses only pick the + * strategy via {@link #configurationServiceOverrider()}. + */ +public abstract class AbstractDeregisterSharedInformerIT { + + private static final String PRIMARY_NAME = "primary1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withAdditionalCustomResourceDefinition(DeregisterWatchedCustomResource.class) + .withReconciler(new DeregisterReconciler()) + .withConfigurationService(configurationServiceOverrider()) + .build(); + + /** The informer pool strategy under test. */ + protected abstract Consumer configurationServiceOverrider(); + + @Test + void deregisteringDynamicEventSourceRemovesInformerFromPool() { + var reconciler = extension.getReconcilerOfType(DeregisterReconciler.class); + var pool = extension.getOperator().getConfigurationService().informerPool(); + + // Create the primary with registration enabled: the reconciler dynamically registers the event + // source for the watched resource. + extension.create(primary(true)); + + // The dynamically registered event source establishes exactly one informer for the watched + // resource in the pool. + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isPositive(); + assertThat(pool.numberOfInformersForResource(DeregisterWatchedCustomResource.class)) + .isEqualTo(1); + }); + + var executionsBeforeDeregister = reconciler.getNumberOfExecutions(); + + // Flip the spec so the next reconciliation de-registers the event source. + var toUpdate = extension.get(DeregisterPrimaryCustomResource.class, PRIMARY_NAME); + toUpdate.getSpec().setRegisterEventSource(false); + extension.replace(toUpdate); + + // After the de-registration reconciliation runs, the informer is released from the pool. + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()) + .isGreaterThan(executionsBeforeDeregister); + assertThat(pool.numberOfInformersForResource(DeregisterWatchedCustomResource.class)) + .isZero(); + }); + } + + DeregisterPrimaryCustomResource primary(boolean registerEventSource) { + var res = new DeregisterPrimaryCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(PRIMARY_NAME).build()); + res.setSpec(new DeregisterSpec().setRegisterEventSource(registerEventSource)); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AlwaysNewInformerPoolDeregisterSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AlwaysNewInformerPoolDeregisterSharedInformerIT.java new file mode 100644 index 0000000000..cfb7e9c83e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/AlwaysNewInformerPoolDeregisterSharedInformerIT.java @@ -0,0 +1,34 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.deregister; + +import java.util.function.Consumer; + +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.AlwaysNewInformerPool; + +/** + * Runs {@link AbstractDeregisterSharedInformerIT} with the {@link AlwaysNewInformerPool}. Since + * only one controller registers/de-registers a single event source, the register-then-release + * lifecycle (and thus the assertions) is identical to the sharing pool. + */ +class AlwaysNewInformerPoolDeregisterSharedInformerIT extends AbstractDeregisterSharedInformerIT { + + @Override + protected Consumer configurationServiceOverrider() { + return overrider -> overrider.withInformerPool(new AlwaysNewInformerPool()); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterPrimaryCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterPrimaryCustomResource.java new file mode 100644 index 0000000000..7e03081b1a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterPrimaryCustomResource.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.deregister; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** Primary resource of {@link DeregisterReconciler}. */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("dreg-p") +public class DeregisterPrimaryCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterReconciler.java new file mode 100644 index 0000000000..680ce87077 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterReconciler.java @@ -0,0 +1,73 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.deregister; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +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.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Dynamically registers an event source for {@link DeregisterWatchedCustomResource} while the + * primary's spec requests it, and dynamically de-registers it otherwise. Used to verify that + * de-registering a dynamically registered event source releases the underlying informer from the + * pool. + */ +@ControllerConfiguration +public class DeregisterReconciler implements Reconciler { + + public static final String WATCHED_EVENT_SOURCE_NAME = "deregister-watched-informer"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DeregisterPrimaryCustomResource primary, Context context) { + numberOfExecutions.incrementAndGet(); + + if (primary.getSpec() != null && primary.getSpec().isRegisterEventSource()) { + context.eventSourceRetriever().dynamicallyRegisterEventSource(watchedEventSource(context)); + } else { + context.eventSourceRetriever().dynamicallyDeRegisterEventSource(WATCHED_EVENT_SOURCE_NAME); + } + + return UpdateControl.noUpdate(); + } + + private InformerEventSource + watchedEventSource(Context context) { + var config = + InformerEventSourceConfiguration.from( + DeregisterWatchedCustomResource.class, DeregisterPrimaryCustomResource.class) + .withName(WATCHED_EVENT_SOURCE_NAME) + .withSecondaryToPrimaryMapper( + (DeregisterWatchedCustomResource watched) -> + Set.of(new ResourceID("ignored", watched.getMetadata().getNamespace()))) + .build(); + return new InformerEventSource<>( + config, context.eventSourceRetriever().eventSourceContextForDynamicRegistration()); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSharedInformerIT.java new file mode 100644 index 0000000000..8c9a209553 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSharedInformerIT.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.deregister; + +import java.util.function.Consumer; + +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; + +/** Runs {@link AbstractDeregisterSharedInformerIT} with the default (sharing) informer pool. */ +class DeregisterSharedInformerIT extends AbstractDeregisterSharedInformerIT { + + @Override + protected Consumer configurationServiceOverrider() { + return overrider -> {}; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSpec.java new file mode 100644 index 0000000000..c88b0cde9d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterSpec.java @@ -0,0 +1,37 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.deregister; + +/** + * Spec of {@link DeregisterPrimaryCustomResource}; toggles the dynamic event source registration. + */ +public class DeregisterSpec { + + /** + * When {@code true} the reconciler dynamically registers the event source for {@link + * DeregisterWatchedCustomResource}; when {@code false} it de-registers it. + */ + private boolean registerEventSource = true; + + public boolean isRegisterEventSource() { + return registerEventSource; + } + + public DeregisterSpec setRegisterEventSource(boolean registerEventSource) { + this.registerEventSource = registerEventSource; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterWatchedCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterWatchedCustomResource.java new file mode 100644 index 0000000000..9593bbd146 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/deregister/DeregisterWatchedCustomResource.java @@ -0,0 +1,32 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.deregister; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * The custom resource watched via a dynamically registered (and later de-registered) event source + * by {@link DeregisterReconciler}. It has no reconciler of its own. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("dreg-w") +public class DeregisterWatchedCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AbstractDynamicSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AbstractDynamicSharedInformerIT.java new file mode 100644 index 0000000000..befc1dd5f2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AbstractDynamicSharedInformerIT.java @@ -0,0 +1,138 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import java.time.Duration; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Two reconcilers watch the same "third" custom resource ({@link + * DynamicSharedInformerThirdCustomResource}) as a secondary resource, but register their informer + * event sources differently: + * + *

+ * + *

Regardless of the pool strategy, the dynamically registered event source must be triggered for + * the pre-existing third resource: on registration the framework replays the resources already in + * the running informer's cache to the newly added handler, which maps the third resource back to + * the dynamic reconciler's primary. + * + *

What differs by strategy is the number of underlying informers for the third resource: a + * sharing pool establishes a single informer used by both reconcilers, whereas a non-sharing pool + * creates one per reconciler. That expected count is left abstract; concrete subclasses pick the + * strategy via {@link #configurationServiceOverrider()}. + */ +public abstract class AbstractDynamicSharedInformerIT { + + private static final String THIRD_RESOURCE_NAME = "third1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withAdditionalCustomResourceDefinition(DynamicSharedInformerThirdCustomResource.class) + .withReconciler(new StaticSharedInformerReconciler()) + .withReconciler(new DynamicSharedInformerReconciler()) + .withConfigurationService(configurationServiceOverrider()) + .build(); + + /** The informer pool strategy under test. */ + protected abstract Consumer configurationServiceOverrider(); + + /** + * Expected number of informers for the third resource once both reconcilers watch it: {@code 1} + * when the informer is shared, {@code 2} when each reconciler gets its own. + */ + protected abstract long expectedThirdResourceInformerCount(); + + @Test + void dynamicallyRegisteredEventSourceReceivesInitialEvent() { + var staticReconciler = extension.getReconcilerOfType(StaticSharedInformerReconciler.class); + var dynamicReconciler = extension.getReconcilerOfType(DynamicSharedInformerReconciler.class); + + // The static reconciler's primary must exist so that third-resource events (which map to it) + // actually result in a reconciliation. + extension.create(primary1()); + // The third resource is created before the dynamic event source is registered, so it is a + // pre-existing resource from the perspective of the dynamically added handler. + extension.create(thirdResource()); + + // Sanity check that the informer machinery works at all: the static reconciler, whose handler + // was present from startup, is triggered by the (live) creation of the third resource. + await().untilAsserted(() -> assertThat(staticReconciler.getNumberOfExecutions()).isPositive()); + + // Creating the second primary triggers the dynamic reconciler, which registers its own event + // source for the third resource against an already-running informer. + extension.create(primary2()); + await().untilAsserted(() -> assertThat(dynamicReconciler.getNumberOfExecutions()).isPositive()); + + // (1) Informer count for the third resource, which depends on the pool strategy. + var pool = extension.getOperator().getConfigurationService().informerPool(); + await() + .untilAsserted( + () -> + assertThat( + pool.numberOfInformersForResource( + DynamicSharedInformerThirdCustomResource.class)) + .isEqualTo(expectedThirdResourceInformerCount())); + + // (2) The dynamically registered event source now watches the pre-existing third resource. On + // registration the framework replays the resources already in the running informer's cache to + // the newly added handler, which maps the third resource back to the dynamic reconciler's + // primary. This triggers a second reconciliation, in addition to the first one (the primary2 + // creation) that performed the registration. Without the replay the dynamic reconciler would + // only ever run once. + await() + .atMost(Duration.ofSeconds(15)) + .untilAsserted( + () -> assertThat(dynamicReconciler.getNumberOfExecutions()).isGreaterThanOrEqualTo(2)); + } + + DynamicSharedInformerPrimaryCustomResource1 primary1() { + var res = new DynamicSharedInformerPrimaryCustomResource1(); + res.setMetadata( + new ObjectMetaBuilder().withName(StaticSharedInformerReconciler.PRIMARY_NAME).build()); + return res; + } + + DynamicSharedInformerPrimaryCustomResource2 primary2() { + var res = new DynamicSharedInformerPrimaryCustomResource2(); + res.setMetadata( + new ObjectMetaBuilder().withName(DynamicSharedInformerReconciler.PRIMARY_NAME).build()); + return res; + } + + DynamicSharedInformerThirdCustomResource thirdResource() { + var res = new DynamicSharedInformerThirdCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(THIRD_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AlwaysNewInformerPoolDynamicSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AlwaysNewInformerPoolDynamicSharedInformerIT.java new file mode 100644 index 0000000000..f5802bbb4f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/AlwaysNewInformerPoolDynamicSharedInformerIT.java @@ -0,0 +1,41 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import java.util.function.Consumer; + +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.AlwaysNewInformerPool; + +/** + * Runs {@link AbstractDynamicSharedInformerIT} with the {@link AlwaysNewInformerPool}: the static + * and dynamic reconcilers each get their own informer for the third resource. The dynamic + * reconciler is still triggered for the pre-existing third resource, because its own (newly + * created) informer replays the cache to the handler once it syncs. + */ +class AlwaysNewInformerPoolDynamicSharedInformerIT extends AbstractDynamicSharedInformerIT { + + @Override + protected Consumer configurationServiceOverrider() { + return overrider -> overrider.withInformerPool(new AlwaysNewInformerPool()); + } + + @Override + protected long expectedThirdResourceInformerCount() { + // no sharing: one informer for the static reconciler and one for the dynamic reconciler + return 2; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerIT.java new file mode 100644 index 0000000000..b39664a0c2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerIT.java @@ -0,0 +1,37 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import java.util.function.Consumer; + +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; + +/** + * Runs {@link AbstractDynamicSharedInformerIT} with the default (sharing) informer pool: the static + * and dynamic reconcilers share a single informer for the third resource. + */ +class DynamicSharedInformerIT extends AbstractDynamicSharedInformerIT { + + @Override + protected Consumer configurationServiceOverrider() { + return overrider -> {}; + } + + @Override + protected long expectedThirdResourceInformerCount() { + return 1; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource1.java new file mode 100644 index 0000000000..7515f3b971 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource1.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** Primary resource of {@link StaticSharedInformerReconciler}. */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("dsi1") +public class DynamicSharedInformerPrimaryCustomResource1 extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource2.java new file mode 100644 index 0000000000..55b16d3ab8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerPrimaryCustomResource2.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** Primary resource of {@link DynamicSharedInformerReconciler}. */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("dsi2") +public class DynamicSharedInformerPrimaryCustomResource2 extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerReconciler.java new file mode 100644 index 0000000000..1779a1b43e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerReconciler.java @@ -0,0 +1,81 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +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.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Watches the same {@link DynamicSharedInformerThirdCustomResource} ("third" custom resource) as + * {@link StaticSharedInformerReconciler}, but registers its informer event source + * dynamically from within {@link #reconcile} instead of statically at startup. Since the + * configuration matches the static reconciler's, the pool is expected to hand out the same, + * already-running informer, so the two reconcilers share a single informer for the third resource. + * + *

The event source is registered while the shared informer is already running and has the + * pre-existing third resource in its cache. Because the handler is added to an already-running + * informer, the framework replays the resources already in that informer's cache to this newly + * added handler on registration, so this reconciler is triggered for the pre-existing third + * resource. This is asserted by the integration test. + */ +@ControllerConfiguration +public class DynamicSharedInformerReconciler + implements Reconciler { + + public static final String PRIMARY_NAME = "dynamic-primary"; + public static final String THIRD_EVENT_SOURCE_NAME = "dynamic-third-informer"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DynamicSharedInformerPrimaryCustomResource2 resource, + Context context) { + numberOfExecutions.incrementAndGet(); + context + .eventSourceRetriever() + .dynamicallyRegisterEventSource(thirdResourceEventSource(context)); + return UpdateControl.noUpdate(); + } + + private InformerEventSource< + DynamicSharedInformerThirdCustomResource, DynamicSharedInformerPrimaryCustomResource2> + thirdResourceEventSource(Context context) { + var config = + InformerEventSourceConfiguration.from( + DynamicSharedInformerThirdCustomResource.class, + DynamicSharedInformerPrimaryCustomResource2.class) + .withName(THIRD_EVENT_SOURCE_NAME) + .withSecondaryToPrimaryMapper( + (DynamicSharedInformerThirdCustomResource third) -> + Set.of(new ResourceID(PRIMARY_NAME, third.getMetadata().getNamespace()))) + .build(); + return new InformerEventSource<>( + config, context.eventSourceRetriever().eventSourceContextForDynamicRegistration()); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerThirdCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerThirdCustomResource.java new file mode 100644 index 0000000000..fb4ad62586 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerThirdCustomResource.java @@ -0,0 +1,34 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * The "third" custom resource that is watched as a secondary resource by two different reconcilers. + * It has no reconciler of its own. One reconciler watches it via a statically registered event + * source, the other via a dynamically registered one; both are expected to share a single informer + * for this type from the informer pool. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("dsi3") +public class DynamicSharedInformerThirdCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/StaticSharedInformerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/StaticSharedInformerReconciler.java new file mode 100644 index 0000000000..be7bce6ce6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/StaticSharedInformerReconciler.java @@ -0,0 +1,73 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Watches {@link DynamicSharedInformerThirdCustomResource} (the "third" custom resource) as a + * secondary resource using a statically registered informer event source. Because this + * event source is registered at startup, its handler is present on the underlying informer before + * any third resource exists, so this reconciler is triggered by third-resource events. It is the + * counterpart to {@link DynamicSharedInformerReconciler}, which watches the same third resource but + * registers its event source dynamically. + */ +@ControllerConfiguration +public class StaticSharedInformerReconciler + implements Reconciler { + + public static final String PRIMARY_NAME = "static-primary"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var config = + InformerEventSourceConfiguration.from( + DynamicSharedInformerThirdCustomResource.class, + DynamicSharedInformerPrimaryCustomResource1.class) + .withSecondaryToPrimaryMapper( + (DynamicSharedInformerThirdCustomResource third) -> + Set.of(new ResourceID(PRIMARY_NAME, third.getMetadata().getNamespace()))) + .build(); + return List.of(new InformerEventSource<>(config, context)); + } + + @Override + public UpdateControl reconcile( + DynamicSharedInformerPrimaryCustomResource1 resource, + Context context) { + numberOfExecutions.incrementAndGet(); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java index a44b3e13f3..c61ff90140 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java @@ -28,6 +28,7 @@ import io.javaoperatorsdk.operator.api.config.*; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -127,6 +128,12 @@ public Set getKnownReconcilerNames() { public Version getVersion() { return null; } + + // only used here to obtain the resource cloner, so the pool is never accessed + @Override + public InformerPool informerPool() { + return null; + } }.getResourceCloner(); } } From ce6281ecaf5e67cf95970f3cfae0237934cdbd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 16:09:43 +0200 Subject: [PATCH 02/10] Potential fix for pull request finding 'Missing Override annotation' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Attila Mészáros --- .../event/source/informer/pool/AbstractInformerPool.java | 1 + 1 file changed, 1 insertion(+) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java index e61496161c..d0fdd3a4bc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java @@ -46,6 +46,7 @@ public ConfigurationService getConfigurationService() { return configurationService; } + @Override public void setConfigurationService(ConfigurationService configurationService) { this.configurationService = configurationService; } From a13c195c35ab9b075b8916f01f33063f5184bf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 16:10:04 +0200 Subject: [PATCH 03/10] Potential fix for pull request finding 'Missing Override annotation' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Attila Mészáros --- .../event/source/informer/pool/DefaultInformerPool.java | 1 + 1 file changed, 1 insertion(+) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java index 63c5bc1fc2..cfd9e270f7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java @@ -39,6 +39,7 @@ public DefaultInformerPool(ConfigurationService service) { setConfigurationService(service); } + @Override @SuppressWarnings("unchecked") @Override public SharedIndexInformer getInformer( From 08370902ac4e5ac58f9d998ec0f121f32d7c52c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 16:26:47 +0200 Subject: [PATCH 04/10] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros # Conflicts: # operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java --- .../operator/api/config/AbstractConfigurationService.java | 3 ++- .../event/source/informer/pool/DefaultInformerPool.java | 6 ------ .../event/source/informer/pool/InformerPool.java | 1 + .../source/informer/pool/DefaultInformerPoolTest.java | 8 +++++++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java index e4ce563b62..9c8c52a4bc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java @@ -200,7 +200,8 @@ public synchronized InformerPool informerPool() { // can therefore share the underlying informers; synchronized so concurrent first-access from // multiple controllers cannot create (and share out) more than one pool instance if (informerPool == null) { - informerPool = new DefaultInformerPool(this); + informerPool = new DefaultInformerPool(); + informerPool.setConfigurationService(this); } return informerPool; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java index cfd9e270f7..66b341cce1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java @@ -26,7 +26,6 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.informers.SharedIndexInformer; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; public class DefaultInformerPool extends AbstractInformerPool { @@ -35,11 +34,6 @@ public class DefaultInformerPool extends AbstractInformerPool { private final Map, SharedIndexInformer> informers = new HashMap<>(); private final Map, AtomicInteger> counters = new HashMap<>(); - public DefaultInformerPool(ConfigurationService service) { - setConfigurationService(service); - } - - @Override @SuppressWarnings("unchecked") @Override public SharedIndexInformer getInformer( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java index c6efee2026..5b8d864210 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java @@ -54,5 +54,6 @@ Optional> releaseInformer( */ long numberOfInformersForResource(Class resourceClass); + /** Injecting the configuration service, so users have a cleaner API creating the pool. */ void setConfigurationService(ConfigurationService configurationService); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java index c417f3b31d..983494fd03 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer.pool; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.client.KubernetesClient; @@ -38,7 +39,12 @@ class DefaultInformerPoolTest { private static final String ES_NAME = "event-source"; private final KubernetesClient client = MockKubernetesClient.client(TestCustomResource.class); - private final DefaultInformerPool pool = new DefaultInformerPool(new BaseConfigurationService()); + private final DefaultInformerPool pool = new DefaultInformerPool(); + + @BeforeEach + void setup() { + pool.setConfigurationService(new BaseConfigurationService()); + } @Test void sharesSingleInformerForTheSameClassifier() { From fde60f4d026cf428a140cf4fd08390c7089451d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 19:37:17 +0200 Subject: [PATCH 05/10] cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/EventSourceManager.java | 6 ++--- .../processing/event/source/EventSource.java | 23 +++---------------- .../source/informer/InformerEventSource.java | 4 ++-- .../informer/ManagedInformerEventSource.java | 7 +----- .../event/EventSourceManagerTest.java | 4 ++-- .../source/AbstractEventSourceTestBase.java | 2 +- 6 files changed, 11 insertions(+), 35 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java index 83f3648dae..b03d69369e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java @@ -127,7 +127,7 @@ private void logEventSourceEvent(EventSource eventSource, String event) { private Void startEventSource(EventSource eventSource) { try { logEventSourceEvent(eventSource, "Starting"); - eventSource.start(false); + eventSource.start(); logEventSourceEvent(eventSource, "Started"); } catch (MissingCRDException e) { throw e; // leave untouched @@ -249,9 +249,7 @@ public EventSource dynamicallyRegisterEventSource(EventSource ev registerEventSource(eventSource); } } - // The start itself is blocking thus blocking only the threads which are attempt to start the - // actual event source. Think of this as a form of lock striping. - eventSource.start(true); + eventSource.start(); return eventSource; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java index cc4e103e0d..9bd301d77a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java @@ -19,9 +19,9 @@ import java.util.Set; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.health.EventSourceHealthIndicator; import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.LifecycleAware; import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter; @@ -37,7 +37,8 @@ * @param

the primary resource type which reconciler needs to be triggered when events occur on * resources of type R */ -public interface EventSource extends EventSourceHealthIndicator { +public interface EventSource + extends LifecycleAware, EventSourceHealthIndicator { static String generateName(EventSource eventSource) { return eventSource.getClass().getName() + "@" + Integer.toHexString(eventSource.hashCode()); @@ -118,22 +119,4 @@ default Optional getSecondaryResource(P primary) { default Status getStatus() { return Status.UNKNOWN; } - - /** - * Start the event source. Normally, should synchronously populate caches, before the method - * returns. - * - * @since 5.6.0 - * @param dynamicRegistration true if event source registered dynamically, false otherwise - */ - default void start(boolean dynamicRegistration) { - start(); - } - - /** Only for backwards compatible purposes. Use {@link #start(boolean)}. */ - @Deprecated(forRemoval = true) - void start() throws OperatorException; - - /** Strop the event source */ - void stop() throws OperatorException; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 652c50b140..2d6a232b82 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -167,11 +167,11 @@ protected void handleEvent( } @Override - public synchronized void start(boolean dynamicRegistration) { + public synchronized void start() { if (isRunning()) { return; } - super.start(dynamicRegistration); + super.start(); // this makes sure that on first reconciliation all resources are // present on the index manager().list().forEach(r -> primaryToSecondaryIndex.onAddOrUpdate(r, null)); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index c2ee8a5899..ec08f93d93 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -156,7 +156,7 @@ protected abstract void handleEvent( Set relatedPrimaryIDs); @Override - public synchronized void start(boolean dynamicRegistration) { + public synchronized void start() { if (isRunning()) { return; } @@ -173,11 +173,6 @@ public synchronized void start(boolean dynamicRegistration) { super.start(); } - @Override - public void start() throws OperatorException { - start(false); - } - @Override public synchronized void stop() { if (!isRunning()) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java index ec39b30409..f47b72820f 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java @@ -96,8 +96,8 @@ public void startCascadesToEventSources() { eventSourceManager.start(); - verify(eventSource, times(1)).start(false); - verify(eventSource2, times(1)).start(false); + verify(eventSource, times(1)).start(); + verify(eventSource2, times(1)).start(); } @Test diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java index c885a887d9..58284c9a8d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java @@ -65,7 +65,7 @@ public void setUpSource( source.setEventHandler(eventHandler); if (start) { - source.start(false); + source.start(); } } } From 182c31ca2259c117017e42cc9b9be3c3aa1effd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 21:07:22 +0200 Subject: [PATCH 06/10] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../informer/pool/AlwaysNewInformerPool.java | 16 +++++++++++- .../pool/AlwaysNewInformerPoolTest.java | 26 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java index 40b28ea04d..6e808e40d2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java @@ -25,6 +25,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.javaoperatorsdk.operator.OperatorException; @SuppressWarnings({"unchecked", "rawtypes"}) public class AlwaysNewInformerPool extends AbstractInformerPool { @@ -39,8 +40,21 @@ public SharedIndexInformer getInformer( String name, InformerClassifier classifier, KubernetesClient client) { + var key = new ClassifierWithName(controllerName, name, classifier); + if (informers.containsKey(key)) { + throw new OperatorException( + "Informer already registered for controller: " + + controllerName + + ", event source: " + + name + + ", classifier: " + + classifier + + ". This pool creates a dedicated informer per controller/event source and never" + + " shares them, so requesting one twice for the same combination without releasing" + + " the previous one first would leak the earlier informer."); + } var informer = createInformer(classifier, client); - informers.put(new ClassifierWithName(controllerName, name, classifier), informer); + informers.put(key, informer); return informer; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java index 32555aea7a..25196c5074 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPoolTest.java @@ -20,10 +20,12 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -70,13 +72,33 @@ void createsSeparateInformerForDifferentEventSourceNames() { } @Test - void createsNewInformerEvenForAnIdenticalKey() { + void throwsWhenRequestingAnInformerForAnAlreadyRegisteredKey() { var classifier = classifier("default"); pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + + // requesting the same controller+event source+classifier combination again without releasing + // first would otherwise silently overwrite the map entry and leak the earlier informer + assertThatThrownBy(() -> pool.getInformer(CONTROLLER, ES_NAME, classifier, client)) + .isInstanceOf(OperatorException.class) + .hasMessageContaining(CONTROLLER) + .hasMessageContaining(ES_NAME) + .hasMessageContaining(classifier.toString()); + + // the earlier informer is left untouched, still registered exactly once + assertThat(pool.size()).isEqualTo(1); + verify(client, times(1)).resources(TestCustomResource.class); + } + + @Test + void allowsReRequestingAnInformerAfterItWasReleased() { + var classifier = classifier("default"); + pool.getInformer(CONTROLLER, ES_NAME, classifier, client); + pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + + // must not throw: releasing frees up the key for reuse pool.getInformer(CONTROLLER, ES_NAME, classifier, client); - // the same key overwrites the map entry, but a fresh informer is created on every call assertThat(pool.size()).isEqualTo(1); verify(client, times(2)).resources(TestCustomResource.class); } From f70459c3fc71d25d56663adb098305898ef4d21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 21:12:48 +0200 Subject: [PATCH 07/10] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/source/informer/InformerEventSource.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 2d6a232b82..a7ab8bd9c1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -57,8 +57,7 @@ public InformerEventSource( this(configuration); } - @SuppressWarnings({"unchecked", "rawtypes"}) - InformerEventSource(InformerEventSourceConfiguration configuration) { + public InformerEventSource(InformerEventSourceConfiguration configuration) { super(configuration.name(), configuration); // If there is a primary to secondary mapper there is no need for primary to secondary index. primaryToSecondaryMapper = configuration.getPrimaryToSecondaryMapper(); From aa978c23252a7621f4ee8001bb36838ecb1b6dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 21:32:26 +0200 Subject: [PATCH 08/10] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../InformerEventSourceConfiguration.java | 1 + .../source/informer/InformerEventSource.java | 5 ++++ .../source/informer/InformerManager.java | 13 +++++----- .../informer/pool/AbstractInformerPool.java | 25 +++++-------------- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java index c04ec3f07b..b6f7939728 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java @@ -76,6 +76,7 @@ default boolean followControllerNamespaceChanges() {

PrimaryToSecondaryMapper

getPrimaryToSecondaryMapper(); + // todo deprecate Optional getGroupVersionKind(); default String name() { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index a7ab8bd9c1..cb0fdaa8dd 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -52,6 +52,11 @@ public class InformerEventSource private final PrimaryToSecondaryIndex primaryToSecondaryIndex; private final PrimaryToSecondaryMapper

primaryToSecondaryMapper; + /** + * @deprecated use {@link InformerEventSource(InformerEventSourceConfiguration)} + */ + // todo migrate sample, separate PR? + @Deprecated(forRemoval = true) public InformerEventSource( InformerEventSourceConfiguration configuration, EventSourceContext

context) { this(configuration); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java index d8c23fb68d..a18af8eca9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java @@ -104,21 +104,22 @@ private void initSources() { public void changeNamespaces(Set namespaces) { var sourcesToRemove = - sources.keySet().stream().filter(k -> !namespaces.contains(k)).collect(Collectors.toSet()); + sources.entrySet().stream() + .filter(e -> !namespaces.contains(e.getKey())) + .collect(Collectors.toSet()); log.debug("Stopped informer {} for namespaces: {}", this, sourcesToRemove); sourcesToRemove.forEach( - k -> { + e -> { var informer = informerPool.releaseInformer( controllerConfiguration.getName(), configuration.getInformerConfig().getName(), - getClassifier(k)); - sources.remove(k); + e.getValue().getClassifier()); + sources.remove(e.getKey()); informer.ifPresent(i -> i.removeEventHandler(eventHandler)); }); - var newNamespaces = - namespaces.stream().filter(ns -> !sources.containsKey(ns)).collect(Collectors.toList()); + var newNamespaces = namespaces.stream().filter(ns -> !sources.containsKey(ns)).toList(); if (newNamespaces.isEmpty()) { return; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java index d0fdd3a4bc..8cb0ddde34 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java @@ -67,27 +67,14 @@ protected SharedIndexInformer createInformer( FilterWatchListDeletable filteredClient; if (WATCH_ALL_NAMESPACES.equals(classifier.namespaceIdentifier())) { - filteredClient = - clientWithResource - .inAnyNamespace() - .withLabelSelector(classifier.labelSelector()) - .withShardSelector(classifier.shardSelector()); + filteredClient = clientWithResource.inAnyNamespace(); } else { - filteredClient = - clientWithResource - .inNamespace(classifier.namespaceIdentifier()) - .withLabelSelector(classifier.labelSelector()) - .withShardSelector(classifier.shardSelector()); - } - - if (classifier.labelSelector() != null) { - filteredClient = - (FilterWatchListDeletable) filteredClient.withLabelSelector(classifier.labelSelector()); - } - if (classifier.shardSelector() != null) { - filteredClient = - (FilterWatchListDeletable) filteredClient.withShardSelector(classifier.shardSelector()); + filteredClient = clientWithResource.inNamespace(classifier.namespaceIdentifier()); } + filteredClient = + (FilterWatchListDeletable) filteredClient.withLabelSelector(classifier.labelSelector()); + filteredClient = + (FilterWatchListDeletable) filteredClient.withShardSelector(classifier.shardSelector()); if (classifier.fieldSelector() != null && !classifier.fieldSelector().getFields().isEmpty()) { for (var f : classifier.fieldSelector().getFields()) { From d6585a6bf311159ec62e83a641fe1460c0ef3e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 22 Jul 2026 21:35:41 +0200 Subject: [PATCH 09/10] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Attila Mészáros --- .../event/source/informer/pool/AbstractInformerPool.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java index 8cb0ddde34..bc656709c2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java @@ -133,11 +133,7 @@ public void start( final var name = thread.getName(); try { thread.setName( - "InformerInfo[informerInfo" - + informer.getApiTypeClass().getSimpleName() - + "]" - + " " - + thread.getId()); + "InformerInfo[" + informer.getApiTypeClass().getSimpleName() + "] " + thread.getId()); final var resourceName = informer.getApiTypeClass().getSimpleName(); // idempotent: if the informer was already started (e.g. by the pool when it was // created/reused), this just returns the existing start future without restarting it @@ -179,7 +175,7 @@ public void start( private String versionedFullResourceName(SharedIndexInformer informer) { final var apiTypeClass = informer.getApiTypeClass(); - if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) { + if (GenericKubernetesResource.class.isAssignableFrom(apiTypeClass)) { return GenericKubernetesResource.class.getSimpleName(); } return ReconcilerUtilsInternal.getResourceTypeNameWithVersion(apiTypeClass); From 90423d1bba526946e65c2d0d99a6b88ca2f8a510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 23 Jul 2026 09:34:31 +0200 Subject: [PATCH 10/10] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../event/source/informer/InformerWrapper.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java index 596b694d7b..3183576e4a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java @@ -25,10 +25,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.informers.ResourceEventHandler; import io.fabric8.kubernetes.client.informers.SharedIndexInformer; import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.Status; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -105,7 +107,15 @@ public String toString() { } private String informerInfo() { - return "InformerWrapper [informerInfo" + informer.getApiTypeClass().getSimpleName() + "]"; + return "InformerWrapper [ " + versionedFullResourceName() + " ]"; + } + + private String versionedFullResourceName() { + final var apiTypeClass = informer.getApiTypeClass(); + if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) { + return GenericKubernetesResource.class.getSimpleName(); + } + return ReconcilerUtilsInternal.getResourceTypeNameWithVersion(apiTypeClass); } @Override