> prepareEventSources(EventSourceContext context
1); // setting max size for testing purposes
var es =
- new InformerEventSource<>(
+ new InformerEventSource(
InformerEventSourceConfiguration.from(ConfigMap.class, primaryClass())
.withItemStore(boundedItemStore)
.withSecondaryToPrimaryMapper(
Mappers.fromOwnerReferences(
context.getPrimaryResourceClass(),
this instanceof BoundedCacheClusterScopeTestReconciler))
- .build(),
- context);
+ .build());
return List.of(es);
}
diff --git a/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
index 2b3e317baa..538c52c00c 100644
--- a/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
+++ b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
@@ -289,6 +289,41 @@ If you encounter this issue on an older Kubernetes version, consider changing yo
that resource, or even upgrading your Kubernetes version. If you encounter it on a newer Kubernetes version, please log
an issue with the JOSDK and with upstream Kubernetes.
+### Detecting dependent resource API version changes (experimental)
+
+When a dependent resource's CRD gains a new API version and the operator is upgraded to target it,
+comparing `actualResource.getApiVersion()` with the desired resource's API version is not a
+reliable way to detect resources that still need to be updated: the Kubernetes API server serves a
+resource using the requested, served API version regardless of which version it is actually stored
+as, so this comparison would always trivially match.
+
+`KubernetesDependentResource` therefore ignores `apiVersion` when matching. To still force a
+one-time update of dependent resources after such an upgrade, without triggering an update on every
+reconciliation, `KubernetesDependent` provides the opt-in, experimental
+`detectApiVersionChange` flag:
+
+```java
+@KubernetesDependent(detectApiVersionChange = true)
+public class MyDependentResource extends CRUDKubernetesDependentResource {
+ // ...
+}
+```
+
+When enabled, JOSDK records the API version it applies in the `javaoperatorsdk.io/last-applied-api-version`
+annotation. On subsequent reconciliations, the resource is considered mismatched (and thus updated)
+if that recorded marker differs from the API version the operator currently uses - this also
+covers resources that predate this feature and therefore have no marker at all. Once the resource
+has been updated, the marker matches the current API version again, so no further update is
+requested until the API version changes again.
+
+This is disabled by default: existing behavior, including for resources created before this
+feature existed, is unaffected unless you opt in. It does not read or infer the actual storage
+version of the resource from the Kubernetes API, since that information is not reliably exposed;
+it only tracks what the operator itself last applied. It is also not a replacement for
+Kubernetes' [StorageVersionMigration](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/storage-version-migration/),
+which addresses migrating the stored representation of resources, a concern orthogonal to this
+feature.
+
## Telling JOSDK how to find which secondary resources are associated with a given primary resource
[`KubernetesDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java)
@@ -445,6 +480,17 @@ also be created, one per dependent resource.
See [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent)
as a sample.
+Note that an external resource and the state resource referencing it cannot be created atomically:
+the external resource has to be created first, since its identifier is what gets stored in the
+state. If the resources are fetched based on the state - which is usually the case, since the
+identifier is only known from the state - a poll happening in between the two steps cannot see the
+new external resource yet. JOSDK keeps such a recently created resource in the cache for the next
+update to avoid creating a duplicate of it, but for a resource that takes longer to become visible,
+it is recommended to resolve the actual resources from the state resources in
+`BulkDependentResource.getSecondaryResources`, as done in the integration test above. The state
+resources are managed by an `InformerEventSource`, thus are always up-to-date regarding the
+operator's own changes.
+
## GenericKubernetesResource based Dependent Resources
In rare circumstances resource handling where there is no class representation or just typeless handling might be
diff --git a/docs/content/en/docs/documentation/event-filters.md b/docs/content/en/docs/documentation/event-filters.md
index 661f3931f3..4965c505c7 100644
--- a/docs/content/en/docs/documentation/event-filters.md
+++ b/docs/content/en/docs/documentation/event-filters.md
@@ -84,7 +84,7 @@ public List> prepareEventSources(
.withOnAddFilter(cm -> true)
.build();
- return List.of(new InformerEventSource<>(informerConfiguration, context));
+ return List.of(new InformerEventSource<>(informerConfiguration));
}
```
diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md
index d2a104737b..e7aea6b065 100644
--- a/docs/content/en/docs/documentation/eventing.md
+++ b/docs/content/en/docs/documentation/eventing.md
@@ -97,7 +97,7 @@ public class WebPageReconciler implements Reconciler {
InformerEventSourceConfiguration.from(Deployment.class, WebPage.class)
.withLabelSelector(SELECTOR)
.build();
- return List.of(new InformerEventSource<>(configuration, context));
+ return List.of(new InformerEventSource<>(configuration));
}
// omitted code
@@ -346,4 +346,70 @@ 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 `KubernetesClient` they watch through, compared by instance: normally every event source
+ resolves the operator's own client, but an event source watching another cluster brings its own
+ (see [multi-cluster](#informereventsource-multi-cluster-support)). Two separate client instances
+ never share an informer, not even when they connect to the same API server — they may differ in
+ credentials, impersonation or TLS material, and the informer keeps using the client it was created
+ from,
+- 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: they are
+registered on the shared informer under a name qualified with the controller and event source that
+added them, so index names are private to an event source and cannot collide with those of another
+one. You keep looking indexes up by the name you registered, and the indexers of an event source are
+removed from the shared informer when it stops using it.
+
+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.
+- [`NonSharingInformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPool.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 NonSharingInformerPool()));
+```
+
+A custom strategy has to extend
+[`AbstractInformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java),
+which is what `withInformerPool` accepts: it already creates the informers from an
+`InformerClassifier` and starts them, leaving the subclass to decide only whether and how they are
+shared. `InformerPool` itself is just the narrower contract that the event sources consume.
diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md
index 1d74cf3f0f..cdfb1b7fdb 100644
--- a/docs/content/en/docs/documentation/operations/configuration.md
+++ b/docs/content/en/docs/documentation/operations/configuration.md
@@ -84,7 +84,7 @@ public class MyReconciler implements Reconciler {
InformerEventSource configMapES =
new InformerEventSource<>(InformerEventSourceConfiguration.from(ConfigMap.class, TestCustomResource.class)
.withNamespacesInheritedFromController(context)
- .build(), context);
+ .build());
return EventSourceUtils.nameEventSources(configMapES);
}
diff --git a/docs/content/en/docs/documentation/working-with-es-caches.md b/docs/content/en/docs/documentation/working-with-es-caches.md
index 07c8a02f1b..d31f1e079f 100644
--- a/docs/content/en/docs/documentation/working-with-es-caches.md
+++ b/docs/content/en/docs/documentation/working-with-es-caches.md
@@ -85,8 +85,7 @@ public class WebPageReconciler implements Reconciler {
configMapEventSource = new InformerEventSource<>(
InformerEventSourceConfiguration.from(ConfigMap.class, WebPage.class)
.withLabelSelector(SELECTOR)
- .build(),
- context);
+ .build());
return List.of(configMapEventSource);
}
@@ -200,7 +199,7 @@ With this index in place, you can retrieve the target resources very efficiently
```java
InformerEventSource clusterInformer =
- new InformerEventSource(
+ new InformerEventSource<>(
InformerEventSourceConfiguration.from(Cluster.class, Job.class)
.withSecondaryToPrimaryMapper(
cluster ->
@@ -214,7 +213,7 @@ With this index in place, you can retrieve the target resources very efficiently
.stream()
.map(ResourceID::fromResource)
.collect(Collectors.toSet()))
- .withNamespacesInheritedFromController().build(), context);
+ .withNamespacesInheritedFromController().build());
```
## Read-cache-after-write consistency and event filtering
diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml
index 55c42b62d8..ae3c4d0be1 100644
--- a/micrometer-support/pom.xml
+++ b/micrometer-support/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdk
java-operator-sdk
- 5.5.2-SNAPSHOT
+ 999-SNAPSHOT
micrometer-support
diff --git a/migration/pom.xml b/migration/pom.xml
index f49af37fc1..cf5143c925 100644
--- a/migration/pom.xml
+++ b/migration/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdk
java-operator-sdk
- 5.5.2-SNAPSHOT
+ 999-SNAPSHOT
migration
diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml
index 8522750c9b..0f974400b1 100644
--- a/operator-framework-bom/pom.xml
+++ b/operator-framework-bom/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdk
operator-framework-bom
- 5.5.2-SNAPSHOT
+ 999-SNAPSHOT
pom
Operator SDK - Bill of Materials
Java SDK for implementing Kubernetes operators
diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml
index cff7aec2ec..a7d06ebdc1 100644
--- a/operator-framework-core/pom.xml
+++ b/operator-framework-core/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdk
java-operator-sdk
- 5.5.2-SNAPSHOT
+ 999-SNAPSHOT
../pom.xml
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..46be5c59c9 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,9 @@
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.AbstractInformerPool;
+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 +38,7 @@ public class AbstractConfigurationService implements ConfigurationService {
private KubernetesClient client;
private Cloner cloner;
private ExecutorServiceManager executorServiceManager;
+ private AbstractInformerPool informerPool;
protected AbstractConfigurationService(Version version) {
this(version, null);
@@ -190,4 +194,16 @@ 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();
+ informerPool.setConfigurationService(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 c3d1636983..0b1d6b47cb 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
@@ -37,6 +37,7 @@
import io.javaoperatorsdk.operator.api.event.DefaultEventRecorder;
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
@@ -44,6 +45,8 @@
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.DefaultInformerPool;
+import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
/** An interface from which to retrieve configuration information. */
public interface ConfigurationService {
@@ -494,4 +497,27 @@ default boolean useSSAToPatchPrimaryResource() {
default boolean cloneSecondaryResourcesWhenGettingFromCache() {
return false;
}
+
+ /**
+ * The informer pool 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
+ */
+ @Experimental(
+ "Only the configuration API around informer pooling could still change in a"
+ + " non-backwards-compatible way, the pooling itself is prod ready.")
+ default InformerPool informerPool() {
+ var pool = new DefaultInformerPool();
+ pool.setConfigurationService(this);
+ return pool;
+ }
}
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 c67af2be99..9f0fd78356 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
@@ -28,7 +28,9 @@
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.Operator;
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
+import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
+import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
@SuppressWarnings({"unused", "UnusedReturnValue"})
public class ConfigurationServiceOverrider {
@@ -54,6 +56,7 @@ public class ConfigurationServiceOverrider {
private Set> defaultNonSSAResource;
private Boolean useSSAToPatchPrimaryResource;
private Boolean cloneSecondaryResourcesWhenGettingFromCache;
+ private InformerPool informerPool;
@SuppressWarnings("rawtypes")
private DependentResourceFactory dependentResourceFactory;
@@ -190,6 +193,21 @@ public ConfigurationServiceOverrider withCloneSecondaryResourcesWhenGettingFromC
return this;
}
+ /**
+ * Overrides the informer pool strategy used to create/share the informers backing the event
+ * sources. When not set, the default (informer-sharing) pool is used.
+ *
+ * Custom strategies implement {@link InformerPool}, which already takes care of creating and
+ * starting the informers.
+ */
+ @Experimental(
+ "Only the configuration API around informer pooling could still change in a"
+ + " non-backwards-compatible way, the pooling itself is prod ready.")
+ public ConfigurationServiceOverrider withInformerPool(InformerPool informerPool) {
+ this.informerPool = informerPool;
+ return this;
+ }
+
public ConfigurationService build() {
return new BaseConfigurationService(original.getVersion(), cloner, client) {
@Override
@@ -330,6 +348,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/Informable.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Informable.java
index 12c6b4fe06..5175efb898 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Informable.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Informable.java
@@ -15,7 +15,10 @@
*/
package io.javaoperatorsdk.operator.api.config;
+import java.util.Optional;
+
import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
public interface Informable {
@@ -29,4 +32,12 @@ default String getResourceTypeName() {
default Class getResourceClass() {
return getInformerConfig().getResourceClass();
}
+
+ /**
+ * Optional, specific kubernetes client, typically to connect to a different cluster than the rest
+ * of the operator. Note that this is solely for multi cluster support.
+ */
+ default Optional getKubernetesClient() {
+ return Optional.empty();
+ }
}
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..1ee1e4e4a7 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,21 @@ 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);
+ }
+
+ @Override
+ public String toString() {
+ return "FieldSelector{" + "fields=" + 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..9fe25c999d 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,9 +93,14 @@ protected InformerConfiguration(
this.comparableResourceVersions = comparableResourceVersions;
}
- private InformerConfiguration(Class resourceClass) {
+ private InformerConfiguration(Class resourceClass, GroupVersionKind resourceGroupVersionKind) {
this.resourceClass = resourceClass;
+ this.resourceGroupVersionKind = resourceGroupVersionKind;
this.resourceTypeName =
+ // note the direction: this is true for GenericKubernetesResource, but also when the
+ // resource
+ // class is a supertype of it - i.e. a plain HasMetadata, for which no type name can be
+ // resolved from @Group/@Version annotations
resourceClass.isAssignableFrom(GenericKubernetesResource.class)
// in general this is irrelevant now for secondary resources it is used just by
// controller
@@ -101,10 +109,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 +126,7 @@ public static InformerConfiguration.Builder builder(
InformerConfiguration original) {
return new InformerConfiguration(
original.resourceClass,
+ original.resourceGroupVersionKind,
original.name,
original.namespaces,
original.followControllerNamespaceChanges,
@@ -305,6 +320,10 @@ public Long getInformerListLimit() {
return informerListLimit;
}
+ public GroupVersionKind getResourceGroupVersionKind() {
+ return resourceGroupVersionKind;
+ }
+
public FieldSelector getFieldSelector() {
return fieldSelector;
}
@@ -500,10 +519,20 @@ public Builder withInformerListLimit(Long informerListLimit) {
}
public Builder withFieldSelector(FieldSelector fieldSelector) {
- InformerConfiguration.this.fieldSelector = fieldSelector;
+ // an empty selector filters nothing, so it must not be distinguishable from having none at
+ // all: the informer pool keys on the field selector, and the annotation path always builds
+ // one (@Informer#fieldSelector defaults to {}) where the programmatic path leaves it null,
+ // which would otherwise stop the two from sharing an informer
+ InformerConfiguration.this.fieldSelector = isEmpty(fieldSelector) ? null : fieldSelector;
return this;
}
+ private static boolean isEmpty(FieldSelector fieldSelector) {
+ return fieldSelector == null
+ || fieldSelector.getFields() == null
+ || fieldSelector.getFields().isEmpty();
+ }
+
public Builder withComparableResourceVersions(boolean comparableResourceVersions) {
InformerConfiguration.this.comparableResourceVersions = comparableResourceVersions;
return this;
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..9bd6f84d06 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,20 +76,16 @@ default boolean followControllerNamespaceChanges() {
PrimaryToSecondaryMapper
getPrimaryToSecondaryMapper();
+ /**
+ * @deprecated use {@link InformerConfiguration#getResourceGroupVersionKind()}
+ */
+ @Deprecated(forRemoval = true)
Optional getGroupVersionKind();
default String name() {
return getInformerConfig().getName();
}
- /**
- * Optional, specific kubernetes client, typically to connect to a different cluster than the rest
- * of the operator. Note that this is solely for multi cluster support.
- */
- default Optional getKubernetesClient() {
- return Optional.empty();
- }
-
class DefaultInformerEventSourceConfiguration
implements InformerEventSourceConfiguration {
private final PrimaryToSecondaryMapper> primaryToSecondaryMapper;
@@ -167,7 +163,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/api/reconciler/PrimaryUpdateAndCacheUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java
index 053bde9e3d..6fd9fd44f5 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java
@@ -24,12 +24,12 @@
import org.slf4j.LoggerFactory;
import io.fabric8.kubernetes.api.model.HasMetadata;
-import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.base.PatchContext;
import io.fabric8.kubernetes.client.dsl.base.PatchType;
import io.javaoperatorsdk.operator.OperatorException;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID;
@@ -434,10 +434,7 @@ public static P addFinalizerWithSSA(
}
try {
P resource = (P) originalResource.getClass().getConstructor().newInstance();
- ObjectMeta objectMeta = new ObjectMeta();
- objectMeta.setName(originalResource.getMetadata().getName());
- objectMeta.setNamespace(originalResource.getMetadata().getNamespace());
- resource.setMetadata(objectMeta);
+ resource.initNameAndNamespaceFrom(originalResource);
resource.addFinalizer(finalizerName);
return client
.resource(resource)
@@ -460,43 +457,10 @@ public static
P addFinalizerWithSSA(
}
public static int compareResourceVersions(HasMetadata h1, HasMetadata h2) {
- return compareResourceVersions(
- h1.getMetadata().getResourceVersion(), h2.getMetadata().getResourceVersion());
+ return ReconcilerUtilsInternal.validateAndCompareResourceVersions(h1, h2);
}
public static int compareResourceVersions(String v1, String v2) {
- int v1Length = validateResourceVersion(v1);
- int v2Length = validateResourceVersion(v2);
- int comparison = v1Length - v2Length;
- if (comparison != 0) {
- return comparison;
- }
- for (int i = 0; i < v2Length; i++) {
- int comp = v1.charAt(i) - v2.charAt(i);
- if (comp != 0) {
- return comp;
- }
- }
- return 0;
- }
-
- private static int validateResourceVersion(String v1) {
- int v1Length = v1.length();
- if (v1Length == 0) {
- throw new NonComparableResourceVersionException("Resource version is empty");
- }
- for (int i = 0; i < v1Length; i++) {
- char char1 = v1.charAt(i);
- if (char1 == '0') {
- if (i == 0) {
- throw new NonComparableResourceVersionException(
- "Resource version cannot begin with 0: " + v1);
- }
- } else if (char1 < '0' || char1 > '9') {
- throw new NonComparableResourceVersionException(
- "Non numeric characters in resource version: " + v1);
- }
- }
- return v1Length;
+ return ReconcilerUtilsInternal.validateAndCompareResourceVersions(v1, v2);
}
}
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/dependent/kubernetes/GenericKubernetesResourceMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
index b5a0728e16..23fb29151f 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
@@ -38,6 +38,12 @@ public class GenericKubernetesResourceMatcher SPEC_PREFIX = List.of(SPEC);
+ private static final List STATUS_PREFIX = List.of(STATUS);
+ private static final List METADATA_PREFIX = List.of(METADATA);
+ private static final List LABELS_AND_ANNOTATIONS_PREFIX =
+ List.of(METADATA_LABELS, METADATA_ANNOTATIONS);
+
private static final String PATH = "path";
private static final String[] EMPTY_ARRAY = {};
@@ -182,11 +188,11 @@ public static Matcher.Result m
boolean matched = true;
for (int i = 0; i < wholeDiffJsonPatch.size() && matched; i++) {
var node = wholeDiffJsonPatch.get(i);
- if (nodeIsChildOf(node, List.of(SPEC))) {
+ if (nodeIsChildOf(node, SPEC_PREFIX)) {
matched = match(valuesEquality, node, ignoreList);
- } else if (nodeIsChildOf(node, List.of(METADATA))) {
+ } else if (nodeIsChildOf(node, METADATA_PREFIX)) {
// conditionally consider labels and annotations
- if (nodeIsChildOf(node, List.of(METADATA_LABELS, METADATA_ANNOTATIONS))) {
+ if (nodeIsChildOf(node, LABELS_AND_ANNOTATIONS_PREFIX)) {
matched = match(labelsAndAnnotationsEquality, node, Collections.emptyList());
}
} else if (!nodeIsChildOf(node, IGNORED_FIELDS)) {
@@ -241,7 +247,7 @@ public static Matcher.Result m
boolean matched = true;
for (int i = 0; i < wholeDiffJsonPatch.size() && matched; i++) {
var node = wholeDiffJsonPatch.get(i);
- if (nodeIsChildOf(node, List.of(STATUS))) {
+ if (nodeIsChildOf(node, STATUS_PREFIX)) {
matched = match(valuesEquality, node, Collections.emptyList());
}
}
@@ -261,7 +267,12 @@ private static boolean match(boolean equality, JsonNode diff, final List
static boolean nodeIsChildOf(JsonNode n, List prefixes) {
var path = getPath(n);
- return prefixes.stream().anyMatch(path::startsWith);
+ for (int i = 0; i < prefixes.size(); i++) {
+ if (path.startsWith(prefixes.get(i))) {
+ return true;
+ }
+ }
+ return false;
}
static String getPath(JsonNode n) {
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java
index a3ed4d2d97..728673ad25 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java
@@ -53,10 +53,11 @@ protected GroupVersionKindPlural(GroupVersionKind gvk, String plural) {
@Override
protected boolean specificEquals(GroupVersionKind that) {
- if (plural == null) {
- return true;
- }
- return that instanceof GroupVersionKindPlural gvkp && gvkp.plural.equals(plural);
+ // a GroupVersionKind that is not plural-aware carries no plural form, which is the same as an
+ // unspecified one: that keeps this consistent with hashCode(), which only mixes the plural in
+ // when it is present
+ final var thatPlural = that instanceof GroupVersionKindPlural gvkp ? gvkp.plural : null;
+ return Objects.equals(plural, thatPlural);
}
@Override
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java
index 35bcde9052..a23d2b3aa8 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java
@@ -21,6 +21,9 @@
import java.lang.annotation.Target;
import io.javaoperatorsdk.operator.api.config.informer.Informer;
+import io.javaoperatorsdk.operator.api.reconciler.Experimental;
+
+import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@@ -62,4 +65,32 @@ boolean createResourceOnlyIfNotExistingWithSSA() default
*/
Class extends SSABasedGenericKubernetesResourceMatcher> matcher() default
SSABasedGenericKubernetesResourceMatcher.class;
+
+ /**
+ * Whether JOSDK should detect that the API version of this dependent resource's desired state has
+ * changed since it was last applied by the operator (for example after the operator was upgraded
+ * to target a new CRD version) and, in that case, request a one-time update of the actual
+ * resource.
+ *
+ * When enabled, JOSDK records the API version it applies in the {@value
+ * KubernetesDependentResource#LAST_APPLIED_API_VERSION_ANNOTATION_KEY} annotation. On subsequent
+ * reconciliations, the resource is considered mismatched (and thus updated) if that recorded
+ * marker differs from the API version the operator currently uses, including when the marker is
+ * missing entirely (for example on resources created before this feature was enabled). Once the
+ * resource has been updated, the marker matches the current API version again, so no further
+ * update is requested until the API version changes again.
+ *
+ *
This is opt-in and disabled by default: when disabled, no marker annotation is ever added or
+ * read, and matching behavior is unchanged. It does not read or infer the actual storage version
+ * of the resource in Kubernetes, since that information is not reliably exposed by the API
+ * server; it only tracks what the operator itself last applied. It is not a replacement for
+ * Kubernetes' StorageVersionMigration.
+ *
+ * @return {@code true} if API version change detection is enabled, {@code false} otherwise
+ * @since 5.6
+ */
+ @Experimental(API_MIGHT_CHANGE)
+ boolean detectApiVersionChange() default
+ KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE;
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java
index d39066e5d9..00c802867c 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java
@@ -35,6 +35,8 @@ public KubernetesDependentResourceConfig configFrom(
ControllerConfiguration> controllerConfig) {
var createResourceOnlyIfNotExistingWithSSA =
DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA;
+ var detectApiVersionChange =
+ KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE;
Boolean useSSA = null;
SSABasedGenericKubernetesResourceMatcher matcher =
@@ -43,6 +45,7 @@ public KubernetesDependentResourceConfig configFrom(
createResourceOnlyIfNotExistingWithSSA =
configAnnotation.createResourceOnlyIfNotExistingWithSSA();
useSSA = configAnnotation.useSSA().asBoolean();
+ detectApiVersionChange = configAnnotation.detectApiVersionChange();
// check if we have a specific matcher
Class extends KubernetesDependentResource, ?>> dependentResourceClass =
@@ -62,7 +65,11 @@ public KubernetesDependentResourceConfig configFrom(
controllerConfig);
return new KubernetesDependentResourceConfig<>(
- useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher);
+ useSSA,
+ createResourceOnlyIfNotExistingWithSSA,
+ informerConfiguration,
+ matcher,
+ detectApiVersionChange);
}
@SuppressWarnings({"unchecked"})
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
index bb59d6eed6..9a549e4e3b 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
@@ -15,6 +15,7 @@
*/
package io.javaoperatorsdk.operator.processing.dependent.kubernetes;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -52,6 +53,15 @@ public abstract class KubernetesDependentResource kubernetesDependentResourceConfig;
private volatile Boolean useSSA;
@@ -160,6 +170,12 @@ public Result match(R actualResource, R desired, P primary, Context contex
protected void addMetadata(
boolean forMatch, R actualResource, final R target, P primary, Context
context) {
+ if (kubernetesDependentResourceConfig != null
+ && kubernetesDependentResourceConfig.detectApiVersionChange()) {
+ // desired resources might expose a null or immutable annotations map (e.g. Map.of(...));
+ // make sure it's a mutable one before this method or its callees write to it
+ ensureMutableAnnotations(target);
+ }
if (forMatch) { // keep the current previous annotation
String actual =
actualResource
@@ -173,9 +189,36 @@ protected void addMetadata(
annotations.remove(InformerEventSource.PREVIOUS_ANNOTATION_KEY);
}
}
+ addLastAppliedApiVersion(target);
addReferenceHandlingMetadata(target, primary);
}
+ private static void ensureMutableAnnotations(HasMetadata target) {
+ var metadata = target.getMetadata();
+ metadata.setAnnotations(
+ new LinkedHashMap<>(Optional.ofNullable(metadata.getAnnotations()).orElseGet(Map::of)));
+ }
+
+ /**
+ * When {@link KubernetesDependentResourceConfig#detectApiVersionChange()} is enabled, marks the
+ * target resource with the API version the operator is currently applying. Comparing this marker
+ * with the one recorded on the actual resource lets the regular matching logic detect a mismatch,
+ * without ever inspecting the actual, potentially unreliable, stored API version.
+ */
+ private void addLastAppliedApiVersion(R target) {
+ if (kubernetesDependentResourceConfig == null
+ || !kubernetesDependentResourceConfig.detectApiVersionChange()) {
+ return;
+ }
+ var apiVersion = target.getApiVersion();
+ if (apiVersion != null) {
+ target
+ .getMetadata()
+ .getAnnotations()
+ .put(LAST_APPLIED_API_VERSION_ANNOTATION_KEY, apiVersion);
+ }
+ }
+
protected boolean useSSA(Context
context) {
if (useSSA == null) {
useSSA =
@@ -220,7 +263,7 @@ protected InformerEventSource createEventSource(EventSourceContext cont
configBuilder.updateFrom(kubernetesDependentResourceConfig.informerConfig());
}
- var es = new InformerEventSource<>(configBuilder.build(), context);
+ var es = new InformerEventSource(configBuilder.build());
setEventSource(es);
return eventSource().orElseThrow();
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java
index 05ff71335c..b7f8db5439 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java
@@ -21,11 +21,13 @@
public class KubernetesDependentResourceConfig {
public static final boolean DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA = true;
+ public static final boolean DEFAULT_DETECT_API_VERSION_CHANGE = false;
private final Boolean useSSA;
private final boolean createResourceOnlyIfNotExistingWithSSA;
private final InformerConfiguration informerConfig;
private final SSABasedGenericKubernetesResourceMatcher matcher;
+ private final boolean detectApiVersionChange;
public KubernetesDependentResourceConfig(
Boolean useSSA,
@@ -39,11 +41,26 @@ public KubernetesDependentResourceConfig(
boolean createResourceOnlyIfNotExistingWithSSA,
InformerConfiguration informerConfig,
SSABasedGenericKubernetesResourceMatcher matcher) {
+ this(
+ useSSA,
+ createResourceOnlyIfNotExistingWithSSA,
+ informerConfig,
+ matcher,
+ DEFAULT_DETECT_API_VERSION_CHANGE);
+ }
+
+ public KubernetesDependentResourceConfig(
+ Boolean useSSA,
+ boolean createResourceOnlyIfNotExistingWithSSA,
+ InformerConfiguration informerConfig,
+ SSABasedGenericKubernetesResourceMatcher matcher,
+ boolean detectApiVersionChange) {
this.useSSA = useSSA;
this.createResourceOnlyIfNotExistingWithSSA = createResourceOnlyIfNotExistingWithSSA;
this.informerConfig = informerConfig;
this.matcher =
matcher != null ? matcher : SSABasedGenericKubernetesResourceMatcher.getInstance();
+ this.detectApiVersionChange = detectApiVersionChange;
}
public boolean createResourceOnlyIfNotExistingWithSSA() {
@@ -61,4 +78,16 @@ public InformerConfiguration informerConfig() {
public SSABasedGenericKubernetesResourceMatcher matcher() {
return matcher;
}
+
+ /**
+ * Whether JOSDK should detect when the API version of this dependent resource's desired state has
+ * changed since it was last applied by the operator and, in that case, request a one-time update
+ * of the actual resource.
+ *
+ * @return {@code true} if API version change detection is enabled, {@code false} otherwise
+ * @since 5.6
+ */
+ public boolean detectApiVersionChange() {
+ return detectApiVersionChange;
+ }
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java
index bdd6b068b3..3463eea7f1 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java
@@ -24,6 +24,8 @@ public final class KubernetesDependentResourceConfigBuilder informerConfiguration;
private SSABasedGenericKubernetesResourceMatcher matcher;
+ private boolean detectApiVersionChange =
+ KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE;
public KubernetesDependentResourceConfigBuilder() {}
@@ -51,8 +53,18 @@ public KubernetesDependentResourceConfigBuilder withSSAMatcher(
return this;
}
+ public KubernetesDependentResourceConfigBuilder withDetectApiVersionChange(
+ boolean detectApiVersionChange) {
+ this.detectApiVersionChange = detectApiVersionChange;
+ return this;
+ }
+
public KubernetesDependentResourceConfig build() {
return new KubernetesDependentResourceConfig<>(
- useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher);
+ useSSA,
+ createResourceOnlyIfNotExistingWithSSA,
+ informerConfiguration,
+ matcher,
+ detectApiVersionChange);
}
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java
index d3e5b6dbc5..abec13290d 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java
@@ -38,6 +38,7 @@
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.api.model.apps.ReplicaSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
+import io.fabric8.kubernetes.api.model.apps.StatefulSetSpec;
import io.fabric8.kubernetes.client.utils.KubernetesSerialization;
import io.javaoperatorsdk.operator.OperatorException;
import io.javaoperatorsdk.operator.api.reconciler.Context;
@@ -199,25 +200,7 @@ protected void sanitizeState(R actual, R desired, Map actualMap)
&& desired instanceof StatefulSet desiredStatefulSet) {
var actualSpec = actualStatefulSet.getSpec();
var desiredSpec = desiredStatefulSet.getSpec();
- int claims = desiredSpec.getVolumeClaimTemplates().size();
- if (claims == actualSpec.getVolumeClaimTemplates().size()) {
- for (int i = 0; i < claims; i++) {
- var claim = desiredSpec.getVolumeClaimTemplates().get(i);
- if (claim.getSpec().getVolumeMode() == null) {
- Optional.ofNullable(
- GenericKubernetesResource.get(
- actualMap, "spec", "volumeClaimTemplates", i, "spec"))
- .map(Map.class::cast)
- .ifPresent(m -> m.remove("volumeMode"));
- }
- if (claim.getStatus() == null) {
- Optional.ofNullable(
- GenericKubernetesResource.get(actualMap, "spec", "volumeClaimTemplates", i))
- .map(Map.class::cast)
- .ifPresent(m -> m.remove("status"));
- }
- }
- }
+ sanitizeVolumeClaimTemplates(actualMap, actualSpec, desiredSpec);
sanitizePodTemplateSpec(actualMap, actualSpec.getTemplate(), desiredSpec.getTemplate());
} else if (actual instanceof Deployment actualDeployment
&& desired instanceof Deployment desiredDeployment) {
@@ -240,6 +223,29 @@ protected void sanitizeState(R actual, R desired, Map actualMap)
}
}
+ private static void sanitizeVolumeClaimTemplates(
+ Map actualMap, StatefulSetSpec actualSpec, StatefulSetSpec desiredSpec) {
+ int claims = desiredSpec.getVolumeClaimTemplates().size();
+ if (claims != actualSpec.getVolumeClaimTemplates().size()) {
+ return;
+ }
+ for (int i = 0; i < claims; i++) {
+ var claim = desiredSpec.getVolumeClaimTemplates().get(i);
+ if (claim.getSpec().getVolumeMode() == null) {
+ Optional.ofNullable(
+ GenericKubernetesResource.get(actualMap, "spec", "volumeClaimTemplates", i, "spec"))
+ .map(Map.class::cast)
+ .ifPresent(m -> m.remove("volumeMode"));
+ }
+ if (claim.getStatus() == null) {
+ Optional.ofNullable(
+ GenericKubernetesResource.get(actualMap, "spec", "volumeClaimTemplates", i))
+ .map(Map.class::cast)
+ .ifPresent(m -> m.remove("status"));
+ }
+ }
+ }
+
@SuppressWarnings("unchecked")
static void keepOnlyManagedFields(
Map result,
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java
index d3907b657a..665d80063b 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java
@@ -52,7 +52,7 @@ protected AbstractWorkflowExecutor(DefaultWorkflow workflow, P primary, Conte
this.context = context;
this.primaryID = ResourceID.fromResource(primary);
executorService = context.getWorkflowExecutorService();
- results = new HashMap<>(workflow.getDependentResourcesByName().size());
+ results = new HashMap<>(workflow.size());
}
protected abstract Logger logger();
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java
index 374beb91e9..8931e49486 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java
@@ -64,7 +64,7 @@ public class EventProcessor
implements EventHandler, Life
private final Cache
cache;
private final EventSourceManager
eventSourceManager;
private final RateLimiter extends RateLimitState> rateLimiter;
- private final ResourceStateManager resourceStateManager = new ResourceStateManager();
+ private final ResourceStateManager resourceStateManager;
private final Map metricsMetadata;
private ExecutorService executor;
@@ -107,6 +107,8 @@ private EventProcessor(
this.metrics = metrics != null ? metrics : Metrics.NOOP;
this.eventSourceManager = eventSourceManager;
this.rateLimiter = controllerConfiguration.getRateLimiter();
+ this.resourceStateManager =
+ new ResourceStateManager(controllerConfiguration.triggerReconcilerOnAllEvents());
metricsMetadata =
Optional.ofNullable(eventSourceManager.getController())
@@ -194,7 +196,7 @@ private void submitReconciliationExecution(ResourceState state) {
state.getRetry(),
state.deleteEventPresent(),
state.isDeleteFinalStateUnknown());
- state.unMarkEventReceived(triggerOnAllEvents());
+ state.unMarkEventReceived();
metrics.reconciliationSubmitted(latest, state.getRetry(), metricsMetadata);
log.debug("Executing events for custom resource. Scope: {}", executionScope);
executor.execute(new ReconcilerExecutor(resourceID, executionScope));
@@ -249,10 +251,10 @@ private void handleEventMarking(Event event, ResourceState state) {
// removed, but also the informers websocket is disconnected and later reconnected. So
// meanwhile the resource could be deleted and recreated. In this case we just mark a new
// event as below.
- state.markEventReceived(triggerOnAllEvents());
+ state.markEventReceived();
}
} else if (!state.deleteEventPresent() && !state.processedMarkForDeletionPresent()) {
- state.markEventReceived(triggerOnAllEvents());
+ state.markEventReceived();
} else if (isTriggerOnAllEventAndDeleteEventPresent(state)) {
state.markAdditionalEventAfterDeleteEvent();
} else if (log.isDebugEnabled()) {
@@ -381,7 +383,7 @@ private void handleRetryOnException(
boolean eventPresent =
state.eventPresent()
|| (triggerOnAllEvents() && state.isAdditionalEventPresentAfterDeleteEvent());
- state.markEventReceived(triggerOnAllEvents());
+ state.markEventReceived();
retryAwareErrorLogging(
state.getRetry(), eventPresent, errorHandledByReconciler, exception, executionScope);
metrics.reconciliationFailed(
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 9419ebde9a..d553d14cf9 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
@@ -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");
@@ -250,7 +250,9 @@ 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.
+ // actual event source. Think of this as a form of lock striping. Note that two event sources
+ // backed by the same pooled informer may reach this concurrently; starting an already started
+ // informer is a no-op.
eventSource.start();
return eventSource;
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceState.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceState.java
index dac24e7941..89ae8396fa 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceState.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceState.java
@@ -47,6 +47,7 @@ private enum EventingState {
}
private final ResourceID id;
+ private final boolean triggerOnAllEvents;
private boolean underProcessing;
private RetryExecution retry;
@@ -55,8 +56,9 @@ private enum EventingState {
private HasMetadata lastKnownResource;
private boolean isDeleteFinalStateUnknown = false;
- public ResourceState(ResourceID id) {
+ public ResourceState(ResourceID id, boolean triggerOnAllEvents) {
this.id = id;
+ this.triggerOnAllEvents = triggerOnAllEvents;
eventing = EventingState.NO_EVENT_PRESENT;
}
@@ -108,8 +110,8 @@ public boolean processedMarkForDeletionPresent() {
return eventing == EventingState.PROCESSED_MARK_FOR_DELETION;
}
- public void markEventReceived(boolean isAllEventMode) {
- if (!isAllEventMode && deleteEventPresent()) {
+ public void markEventReceived() {
+ if (!triggerOnAllEvents && deleteEventPresent()) {
throw new IllegalStateException("Cannot receive event after a delete event received");
}
log.debug("Marking event received for: {}", getId());
@@ -151,7 +153,7 @@ public HasMetadata getLastKnownResource() {
return lastKnownResource;
}
- public void unMarkEventReceived(boolean isAllEventReconcileMode) {
+ public void unMarkEventReceived() {
switch (eventing) {
case EVENT_PRESENT:
eventing = EventingState.NO_EVENT_PRESENT;
@@ -159,12 +161,12 @@ public void unMarkEventReceived(boolean isAllEventReconcileMode) {
case PROCESSED_MARK_FOR_DELETION:
throw new IllegalStateException("Cannot unmark processed marked for deletion.");
case DELETE_EVENT_PRESENT:
- if (!isAllEventReconcileMode) {
+ if (!triggerOnAllEvents) {
throw new IllegalStateException("Cannot unmark delete event.");
}
break;
case ADDITIONAL_EVENT_PRESENT_AFTER_DELETE_EVENT:
- if (!isAllEventReconcileMode) {
+ if (!triggerOnAllEvents) {
throw new IllegalStateException(
"This state should not happen in non all-event-reconciliation mode");
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManager.java
index 39a94b7735..9b25c7ae0c 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManager.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManager.java
@@ -28,6 +28,11 @@ class ResourceStateManager {
// will process to avoid under- or over-sizing the state maps and avoid too many resizing that
// take time and memory?
private final Map states = new ConcurrentHashMap<>(100);
+ private final boolean triggerOnAllEvents;
+
+ public ResourceStateManager(boolean triggerOnAllEvents) {
+ this.triggerOnAllEvents = triggerOnAllEvents;
+ }
public Optional getOrCreateOnResourceEvent(Event event) {
var resourceId = event.getRelatedCustomResourceID();
@@ -36,7 +41,7 @@ public Optional getOrCreateOnResourceEvent(Event event) {
return Optional.of(state);
}
if (event instanceof ResourceEvent) {
- state = new ResourceState(resourceId);
+ state = new ResourceState(resourceId, triggerOnAllEvents);
states.put(resourceId, state);
return Optional.of(state);
} else {
@@ -45,7 +50,7 @@ public Optional getOrCreateOnResourceEvent(Event event) {
}
public ResourceState getOrCreate(ResourceID resourceID) {
- return states.computeIfAbsent(resourceID, ResourceState::new);
+ return states.computeIfAbsent(resourceID, id -> new ResourceState(id, triggerOnAllEvents));
}
public Optional get(ResourceID resourceID) {
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java
index 61fb2c841a..794c722743 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java
@@ -67,6 +67,30 @@ public abstract class ExternalResourceCachingEventSource> cache = new ConcurrentHashMap<>();
+ /**
+ * The resources written by the reconciler ({@link #handleRecentResourceCreate(ResourceID,
+ * Object)} and {@link #handleRecentResourceUpdate(ResourceID, Object, Object)}) that were not
+ * seen yet in a subsequent update of the whole resource set of a primary. Such an update might
+ * have been created (polled or received) before the resource was actually written, thus not
+ * containing the new state yet. Since these updates are handled as the full actual state, the
+ * write would be lost from the cache; the next reconciliation would then create a duplicate of an
+ * already created resource, or repeat an already executed update. Note that a mark is dropped on
+ * the first update, so a resource really deleted or changed in the meantime is not retained
+ * indefinitely.
+ *
+ * @see #retainUnconfirmedWrites(ResourceID, Map)
+ */
+ private final Map>> unconfirmedWrites =
+ new ConcurrentHashMap<>();
+
+ /**
+ * The last state of a resource written by the reconciler and every state it replaced since the
+ * last update. There can be multiple replaced states if the reconciler wrote the resource more
+ * than once without an update in between; an update created before any of those writes is stale.
+ * The set is empty if the resource was created, since there was no previous state then.
+ */
+ private record RecentWrite(R written, Set replaced) {}
+
protected ExternalResourceCachingEventSource(
Class resourceClass, ResourceIDMapper resourceIDMapper) {
this(null, resourceClass, resourceIDMapper);
@@ -86,6 +110,7 @@ protected ExternalResourceCachingEventSource(
}
protected synchronized void handleDelete(ResourceID primaryID) {
+ unconfirmedWrites.remove(primaryID);
var res = cache.remove(primaryID);
if (res != null && deleteAcceptedByFilter(res.values())) {
getEventHandler().handleEvent(new Event(primaryID));
@@ -105,6 +130,13 @@ protected synchronized void handleDelete(ResourceID primaryID, Set resourceI
if (!isRunning()) {
return;
}
+ var unconfirmed = unconfirmedWrites.get(primaryID);
+ if (unconfirmed != null) {
+ unconfirmed.keySet().removeAll(resourceIDs);
+ if (unconfirmed.isEmpty()) {
+ unconfirmedWrites.remove(primaryID);
+ }
+ }
var cachedValues = cache.get(primaryID);
List removedResources =
cachedValues == null
@@ -131,7 +163,16 @@ protected synchronized void handleResources(ResourceID primaryID, Set newReso
protected synchronized void handleResources(Map> allNewResources) {
var toDelete = cache.keySet().stream().filter(k -> !allNewResources.containsKey(k)).toList();
- toDelete.forEach(this::handleDelete);
+ toDelete.forEach(
+ primaryID -> {
+ if (unconfirmedWrites.containsKey(primaryID)) {
+ // handled as an empty update, so that a recently written resource, that this update
+ // could not see yet, is not removed from the cache
+ handleResources(primaryID, Collections.emptySet());
+ } else {
+ handleDelete(primaryID);
+ }
+ });
allNewResources.forEach(this::handleResources);
}
@@ -148,6 +189,7 @@ protected synchronized void handleResources(
}
var newResourcesMap =
newResources.stream().collect(Collectors.toMap(resourceIDMapper::idFor, r -> r));
+ retainUnconfirmedWrites(primaryID, newResourcesMap);
cache.put(primaryID, newResourcesMap);
if (propagateEvent
&& !newResourcesMap.equals(cachedResources)
@@ -156,6 +198,34 @@ && acceptedByFiler(cachedResources, newResourcesMap)) {
}
}
+ /**
+ * Keeps the resources written since the received update was created, thus missing from it. An
+ * update is considered stale for a written resource if it does not contain it at all - which is
+ * the expected case for a create - or if it still contains a state that a write replaced. Any
+ * other state is a change that happened outside of the reconciler, so it is accepted as the
+ * actual state.
+ *
+ * @see #unconfirmedWrites
+ */
+ private void retainUnconfirmedWrites(ResourceID primaryID, Map newResourcesMap) {
+ var unconfirmed = unconfirmedWrites.remove(primaryID);
+ if (unconfirmed == null) {
+ return;
+ }
+ unconfirmed.forEach(
+ (id, write) -> {
+ var newResource = newResourcesMap.get(id);
+ if (newResource == null || write.replaced().contains(newResource)) {
+ log.debug(
+ "Retaining recently written resource missing from the update. Primary ID: {},"
+ + " resource ID: {}",
+ primaryID,
+ id);
+ newResourcesMap.put(id, write.written());
+ }
+ });
+ }
+
private boolean acceptedByFiler(Map cachedResourceMap, Map newResourcesMap) {
var addedResources = new HashMap<>(newResourcesMap);
@@ -229,6 +299,7 @@ public synchronized void handleRecentResourceCreate(ResourceID primaryID, R reso
} else {
actualValues.computeIfAbsent(resourceId, r -> resource);
}
+ markUnconfirmedWrite(primaryID, resourceId, resource, null);
}
@Override
@@ -240,10 +311,34 @@ public synchronized void handleRecentResourceUpdate(
R actualResource = actualValues.get(resourceId);
if (actualResource != null && actualResource.equals(previousVersionOfResource)) {
actualValues.put(resourceId, resource);
+ markUnconfirmedWrite(primaryID, resourceId, resource, previousVersionOfResource);
}
}
}
+ /**
+ * Marks the written resource as not confirmed yet by an update, keeping the states replaced by
+ * previous writes of the same resource. Without those, an update created before an earlier write
+ * would not be recognized as stale, and the last write would be lost from the cache.
+ *
+ * @param replaced the state the write replaced, {@code null} if the resource was created
+ * @see #unconfirmedWrites
+ */
+ private void markUnconfirmedWrite(ResourceID primaryID, ID resourceId, R written, R replaced) {
+ unconfirmedWrites
+ .computeIfAbsent(primaryID, id -> new HashMap<>())
+ .compute(
+ resourceId,
+ (id, previousWrite) -> {
+ Set replacedStates =
+ previousWrite == null ? new HashSet<>() : new HashSet<>(previousWrite.replaced());
+ if (replaced != null) {
+ replacedStates.add(replaced);
+ }
+ return new RecentWrite<>(written, replacedStates);
+ });
+ }
+
@Override
public Set getSecondaryResources(P primary) {
return getSecondaryResources(ResourceID.fromResource(primary));
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/EventFilterWindow.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterWindow.java
index 826551656e..c63261c0b1 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterWindow.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterWindow.java
@@ -239,8 +239,7 @@ public synchronized void addRelatedEvent(ExtendedResourceEvent event) {
event.setPartOfReList(true);
}
- relatedEvents.put(
- Long.valueOf(event.getResource().orElseThrow().getMetadata().getResourceVersion()), event);
+ relatedEvents.put(event.getResourceVersion(), event);
}
public synchronized void setReListStarted() {
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 d8a8de1189..abde1f6992 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;
@@ -54,20 +52,17 @@ public class InformerEventSource
private final PrimaryToSecondaryIndex primaryToSecondaryIndex;
private final PrimaryToSecondaryMapper primaryToSecondaryMapper;
+ /**
+ * @deprecated use {@link #InformerEventSource(InformerEventSourceConfiguration)}
+ */
+ @Deprecated(forRemoval = true)
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);
+ 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();
if (usePrimaryToSecondaryIndex()) {
@@ -182,7 +177,9 @@ public synchronized void start() {
super.start();
// this makes sure that on first reconciliation all resources are
// present on the index
- manager().list().forEach(r -> primaryToSecondaryIndex.onAddOrUpdate(r, null));
+ if (usePrimaryToSecondaryIndex()) {
+ manager().list().forEach(r -> primaryToSecondaryIndex.onAddOrUpdate(r, null));
+ }
}
@SuppressWarnings("unchecked")
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..6caf39ccd9 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,10 +26,7 @@
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;
@@ -37,39 +34,42 @@
import io.javaoperatorsdk.operator.api.config.Informable;
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
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;
+ // the identity of the event source these informers are managed for, towards the pool and towards
+ // the index names on a shared informer. Deliberately the event source's own name rather than
+ // InformerConfiguration#getName, which is null unless the event source was explicitly named
+ private final String eventSourceName;
private final Map>> indexers = new HashMap<>();
private ControllerConfiguration controllerConfiguration;
+ private InformerPool informerPool;
+ private KubernetesClient targetClient;
- InformerManager(
- MixedOperation, Resource> client,
- C configuration,
- ResourceEventHandler eventHandler) {
- this.client = client;
+ InformerManager(C configuration, ResourceEventHandler eventHandler, String eventSourceName) {
this.configuration = configuration;
this.eventHandler = eventHandler;
+ this.eventSourceName = eventSourceName;
}
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 +78,8 @@ public void start() throws OperatorException {
.getExecutorServiceManager()
.boundedExecuteAndWaitForAllToComplete(
sources.values().stream(),
- iw -> {
- iw.start();
+ wrapper -> {
+ start(wrapper);
return null;
},
iw ->
@@ -96,25 +96,26 @@ 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);
});
}
}
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());
-
- var newNamespaces =
- namespaces.stream().filter(ns -> !sources.containsKey(ns)).collect(Collectors.toList());
+ var namespacesToRemove =
+ sources.keySet().stream()
+ .filter(ns -> !namespaces.contains(ns))
+ .collect(Collectors.toSet());
+ log.debug("Stopped informer {} for namespaces: {}", this, namespacesToRemove);
+ namespacesToRemove.forEach(this::releaseSource);
+
+ var newNamespaces = namespaces.stream().filter(ns -> !sources.containsKey(ns)).toList();
if (newNamespaces.isEmpty()) {
return;
}
@@ -125,79 +126,100 @@ 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(), eventSourceName, classifier);
+ source =
+ new InformerWrapper<>(
+ informer,
+ namespaceIdentifier,
+ classifier,
+ controllerConfiguration.getName(),
+ eventSourceName);
+ sources.put(namespaceIdentifier, source);
source.addIndexers(indexers);
+ source.addEventHandler(eventHandler);
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,
+ 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() {
+ // resolved once: the client is part of the informer classifier's identity, so every classifier
+ // this manager builds (one per watched namespace, and more when namespaces change later on) has
+ // to see the very same instance. ConfigurationService#getKubernetesClient is expected to return
+ // a stable instance, but its default implementation does create a new client on every call.
+ if (targetClient == null) {
+ targetClient =
+ configuration
+ .getKubernetesClient()
+ .orElseGet(
+ () -> controllerConfiguration.getConfigurationService().getKubernetesClient());
}
-
- 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) -> {
- try {
- log.debug("Stopping informer for namespace: {} -> {}", ns, source);
- source.stop();
- } catch (Exception e) {
- log.warn("Error stopping informer for namespace: {} -> {}", ns, source, e);
- }
- });
- sources.clear();
+ sources
+ .keySet()
+ .forEach(
+ ns -> {
+ try {
+ log.debug("Stopping informer for namespace: {}", ns);
+ releaseSource(ns);
+ } catch (Exception e) {
+ log.warn("Error stopping informer for namespace: {}", ns, e);
+ }
+ });
+ }
+
+ /**
+ * Gives the informer backing the given namespace back to the pool, but only if this manager still
+ * holds it: removing it from {@link #sources} is what claims the right to release it. {@link
+ * #stop()} and {@link #changeNamespaces(Set)} can run concurrently, and since the pool
+ * reference-counts its informers, releasing the same namespace twice would consume a reference
+ * another controller still holds and make the pool stop an informer that is still in use.
+ */
+ private void releaseSource(String namespaceIdentifier) {
+ var wrapper = sources.remove(namespaceIdentifier);
+ if (wrapper == null) {
+ return;
+ }
+ // the informer may be shared, in which case it keeps running and would otherwise hold on to
+ // this event source's indexers
+ wrapper.removeIndexers();
+ informerPool
+ .releaseInformer(
+ controllerConfiguration.getName(), eventSourceName, wrapper.getClassifier())
+ .ifPresent(i -> i.removeEventHandler(eventHandler));
}
@Override
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..9548e8c540 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
@@ -15,12 +15,12 @@
*/
package io.javaoperatorsdk.operator.processing.event.source.informer;
+import java.util.HashMap;
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.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;
@@ -30,125 +30,39 @@
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;
+ private final String indexNamePrefix;
+ private final Set registeredIndexNames = ConcurrentHashMap.newKeySet();
public InformerWrapper(
SharedIndexInformer informer,
- ConfigurationService configurationService,
- String namespaceIdentifier) {
+ String namespaceIdentifier,
+ InformerClassifier classifier,
+ String controllerName,
+ String eventSourceName) {
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;
+ this.indexNamePrefix = "josdk/" + controllerName + "/" + eventSourceName + "/";
}
@Override
@@ -187,12 +101,42 @@ public void addEventHandler(ResourceEventHandler eventHandler) {
@Override
public void addIndexers(Map>> indexers) {
- informer.getIndexer().addIndexers(indexers);
+ Map>> qualified = new HashMap<>();
+ indexers.forEach((name, indexer) -> qualified.put(qualify(name), indexer));
+ informer.getIndexer().addIndexers(qualified);
+ registeredIndexNames.addAll(qualified.keySet());
+ }
+
+ /**
+ * Removes the indexers this event source added, to be called when its informer is released. A
+ * shared informer outlives the event sources that stop using it, so without this its indexer
+ * would keep both the index and the (possibly capturing) index function of every event source
+ * that ever used it, and re-registering the same event source later would be rejected as a name
+ * conflict.
+ */
+ void removeIndexers() {
+ registeredIndexNames.forEach(name -> informer.getIndexer().removeIndexer(name));
+ registeredIndexNames.clear();
}
@Override
public List byIndex(String indexName, String indexKey) {
- return informer.getIndexer().byIndex(indexName, indexKey);
+ return informer.getIndexer().byIndex(qualify(indexName), indexKey);
+ }
+
+ /**
+ * The informer can be shared by event sources of several controllers, while its indexer is a
+ * single namespace of index names: two event sources registering the same index name on it would
+ * be rejected by the client, and one could read the other's index. Names are therefore qualified
+ * with the event source that registered them.
+ *
+ * This stays invisible to callers, who keep using their own names, but only for as long as
+ * this class remains the only place that talks to {@link SharedIndexInformer#getIndexer()}:
+ * adding, reading and removing all have to go through here so that the qualification stays
+ * symmetric.
+ */
+ private String qualify(String indexName) {
+ return indexNamePrefix + indexName;
}
@Override
@@ -201,7 +145,15 @@ public String toString() {
}
private String informerInfo() {
- return "InformerWrapper [" + versionedFullResourceName() + "]";
+ return "InformerWrapper [ " + versionedFullResourceName() + " ]";
+ }
+
+ private String versionedFullResourceName() {
+ final var apiTypeClass = informer.getApiTypeClass();
+ if (GenericKubernetesResource.class.isAssignableFrom(apiTypeClass)) {
+ return GenericKubernetesResource.class.getSimpleName();
+ }
+ return ReconcilerUtilsInternal.getResourceTypeNameWithVersion(apiTypeClass);
}
@Override
@@ -237,4 +189,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 1a4dc9fe00..86e4e03d99 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;
}
@@ -85,10 +81,14 @@ protected InformerManager manager() {
}
@Override
- public void changeNamespaces(Set namespaces) {
- if (allowsNamespaceChanges()) {
- manager().changeNamespaces(namespaces);
+ public synchronized void changeNamespaces(Set namespaces) {
+ // a stopped event source has released its informers and its manager holds no sources, so every
+ // requested namespace would look new: it would acquire and start pooled informers that nothing
+ // can ever release, since stop() short-circuits on a non-running event source
+ if (!isRunning() || !allowsNamespaceChanges()) {
+ return;
}
+ manager().changeNamespaces(namespaces);
}
/**
@@ -159,17 +159,31 @@ protected abstract void handleEvent(
Boolean deletedFinalStateUnknown,
Set relatedPrimaryIDs);
- @SuppressWarnings("unchecked")
@Override
public synchronized void start() {
if (isRunning()) {
return;
}
temporaryResourceCache = new TemporaryResourceCache<>(comparableResourceVersions, this);
- this.cache = new InformerManager<>(client, configuration, this);
+ this.cache = new InformerManager<>(configuration, this, name());
cache.setControllerConfiguration(controllerConfiguration);
cache.addIndexers(indexers);
- manager().start();
+ // 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.
+ try {
+ manager().start();
+ } catch (RuntimeException e) {
+ // The manager acquires a pooled informer for every watched namespace before any of them is
+ // started, so a startup failure has to hand those references back here: super.start() is not
+ // reached, which leaves isRunning() false and makes stop() skip the release entirely. The
+ // pooled informer would then be referenced forever (never stopped, even on a clean shutdown)
+ // and a retried start() would acquire it a second time.
+ manager().stop();
+ throw e;
+ }
super.start();
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java
index efc6a981c3..5636fc3893 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java
@@ -124,6 +124,8 @@ private static SecondaryToPrimaryMapper fromMetadata(
String typeKey,
Class extends HasMetadata> primaryResourceType,
boolean isLabel) {
+ final var expectedGvk = GroupVersionKind.gvkFor(primaryResourceType);
+ final var expectedGvkString = expectedGvk.toGVKString();
return resource -> {
final var metadata = resource.getMetadata();
if (metadata == null) {
@@ -143,8 +145,8 @@ private static SecondaryToPrimaryMapper fromMetadata(
String gvkSimple = map.get(typeKey);
if (gvkSimple != null
- && !GroupVersionKind.fromString(gvkSimple)
- .equals(GroupVersionKind.gvkFor(primaryResourceType))) {
+ && !expectedGvkString.equals(gvkSimple)
+ && !GroupVersionKind.fromString(gvkSimple).equals(expectedGvk)) {
return Set.of();
}
@@ -183,7 +185,7 @@ SecondaryToPrimaryMapper fromOwnerType(Class clazz) {
}
return owners.stream()
.filter(it -> kind.equals(it.getKind()))
- .map(it -> new ResourceID(it.getName(), resource.getMetadata().getNamespace()))
+ .map(it -> ResourceID.fromOwnerReference(resource, it, false))
.collect(Collectors.toSet());
};
}
@@ -191,16 +193,16 @@ SecondaryToPrimaryMapper fromOwnerType(Class clazz) {
public static class SecondaryToPrimaryFromDefaultAnnotation
implements SecondaryToPrimaryMapper {
- private final Class extends HasMetadata> primaryResourceType;
+ private final SecondaryToPrimaryMapper delegate;
public SecondaryToPrimaryFromDefaultAnnotation(
Class extends HasMetadata> primaryResourceType) {
- this.primaryResourceType = primaryResourceType;
+ this.delegate = Mappers.fromDefaultAnnotations(primaryResourceType);
}
@Override
public Set toPrimaryResourceIDs(HasMetadata resource) {
- return Mappers.fromDefaultAnnotations(primaryResourceType).toPrimaryResourceIDs(resource);
+ return delegate.toPrimaryResourceIDs(resource);
}
}
}
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..4dc1920955
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java
@@ -0,0 +1,206 @@
+/*
+ * 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.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 io.javaoperatorsdk.operator.api.reconciler.Experimental;
+
+import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACES;
+import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
+
+/**
+ * Base class for the informer pool strategies, and the type the configuration API accepts (see
+ * {@link io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider#withInformerPool}),
+ * so custom strategies are expected to extend this rather than to implement {@link InformerPool}
+ * directly.
+ *
+ * Creating an informer from an {@link InformerClassifier}, starting it and waiting for its cache
+ * to sync, and holding on to the injected {@link ConfigurationService} are handled here. Subclasses
+ * are left with the actual strategy: whether an informer is handed out to more than one event
+ * source and, consequently, when it is stopped.
+ */
+@Experimental(API_MIGHT_CHANGE)
+public abstract class AbstractInformerPool implements InformerPool {
+
+ private static final Logger log = LoggerFactory.getLogger(AbstractInformerPool.class);
+
+ protected ConfigurationService configurationService;
+
+ public ConfigurationService getConfigurationService() {
+ return configurationService;
+ }
+
+ @Override
+ public void setConfigurationService(ConfigurationService configurationService) {
+ this.configurationService = configurationService;
+ }
+
+ /**
+ * 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.
+ */
+ public abstract long numberOfInformersForResource(Class extends HasMetadata> resourceClass);
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ protected SharedIndexInformer createInformer(InformerClassifier> classifier) {
+ var client = classifier.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();
+ } else {
+ 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()) {
+ 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 {
+ throw new IllegalStateException(
+ "Cannot retrieve 'stopped' callback to listen to informer stopping for"
+ + " informer for "
+ + ReconcilerUtilsInternal.getResourceTypeNameWithVersion(
+ informer.getApiTypeClass()));
+ }
+ });
+ 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[" + informer.getApiTypeClass().getSimpleName() + "] " + thread.getId());
+ final var resourceName = informer.getApiTypeClass().getSimpleName();
+ 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 if (ExceptionHandler.isDeserializationException(e)) {
+ // the exception handler installed in createInformer declines a retry for these, and an
+ // informer that is not retried is stopped for good, so don't promise a retry here
+ log.error(
+ "Informer startup error caused by a deserialization problem. The informer is stopped"
+ + " and won't be retried, the operator has to be restarted after the problem is"
+ + " fixed. Informer: {}",
+ informer,
+ 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 extends HasMetadata> informer) {
+ final var apiTypeClass = informer.getApiTypeClass();
+ if (GenericKubernetesResource.class.isAssignableFrom(apiTypeClass)) {
+ 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/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..b561a72458
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java
@@ -0,0 +1,133 @@
+/*
+ * 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.informers.SharedIndexInformer;
+
+public class DefaultInformerPool extends AbstractInformerPool {
+
+ private static final Logger log = LoggerFactory.getLogger(DefaultInformerPool.class);
+
+ /** A pooled informer together with the number of event sources currently sharing it. */
+ private record PooledInformer(SharedIndexInformer> informer, AtomicInteger referenceCount) {}
+
+ private final Map, PooledInformer> informers = new HashMap<>();
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public SharedIndexInformer getInformer(
+ String controllerName, String name, InformerClassifier classifier) {
+ SharedIndexInformer informer;
+ synchronized (this) {
+ var pooled = informers.get(classifier);
+ if (pooled == null) {
+ informer = createInformer(classifier);
+ informers.put(classifier, new PooledInformer(informer, new AtomicInteger(1)));
+ log.debug(
+ "Created new pooled informer for classifier: {}. Requested by controller: {}, event"
+ + " source: {}",
+ classifier,
+ controllerName,
+ name);
+ } else {
+ informer = (SharedIndexInformer) pooled.informer();
+ 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 = pooled.referenceCount().incrementAndGet();
+ log.info(
+ "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 pooled = informers.get(classifier);
+ if (pooled == null) {
+ log.warn("No informer found in the pool for classifier: {}", classifier);
+ return Optional.empty();
+ }
+ var informer = (SharedIndexInformer) pooled.informer();
+ // 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 = pooled.referenceCount().decrementAndGet();
+ if (referenceCount == 0) {
+ 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. */
+ 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 extends HasMetadata> 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..e4023a93e9
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifier.java
@@ -0,0 +1,143 @@
+/*
+ * 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.KubernetesClient;
+import io.fabric8.kubernetes.client.informers.cache.ItemStore;
+import io.javaoperatorsdk.operator.api.config.informer.FieldSelector;
+import io.javaoperatorsdk.operator.processing.GroupVersionKind;
+
+/**
+ * Identifies the informer that backs an event source: two event sources whose classifiers are equal
+ * can be served by one shared informer. It also carries everything needed to create that informer,
+ * including the {@link KubernetesClient} to create it from.
+ *
+ * Note that {@link #equals(Object)} and {@link #hashCode()} deliberately do not
+ * cover every record component:
+ *
+ *
+ * - {@link #informerListLimit()} is excluded, so event sources that only disagree on the list
+ * limit still share an informer; the limit of whichever classifier created the informer is
+ * kept (a pool is expected to warn about this, see {@link
+ * #differsOnlyByInformerListLimit(InformerClassifier)}).
+ *
- Indexers are not part of the classifier at all: they are registered on the informer under a
+ * name qualified with the event source that added them, so those of different event sources
+ * can live side by side on a shared informer without colliding.
+ *
+ *
+ * The {@link #client()} takes part in equality by identity: event sources
+ * sharing an informer must be watching through the very same client, since the informer is created
+ * from (and keeps using) the client of whichever event source established it. Two separate clients
+ * are therefore never assumed to be interchangeable, not even when they connect to the same API
+ * server — they may well differ in credentials, impersonation or TLS material, and the pool cannot
+ * tell.
+ *
+ *
Note that this is also why nothing security relevant from the client's configuration is part
+ * of the classifier: instances end up in log messages and exception messages, so a credential held
+ * here would leak into those.
+ */
+public record InformerClassifier(
+ KubernetesClient client,
+ 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 client == that.client
+ && 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(
+ System.identityHashCode(client),
+ labelSelector,
+ shardSelector,
+ namespaceIdentifier,
+ resourceClass,
+ groupVersionKind,
+ fieldSelector,
+ itemStore);
+ }
+
+ /**
+ * Hand written instead of using the one generated for the record, so that the API server URL is
+ * part of it: classifiers show up in log and exception messages, where the client on its own
+ * identifies the instance but not the cluster it connects to. The URL is derived from the {@link
+ * #client()} rather than held as a component of its own, since it would be redundant for the
+ * identity and could only ever contradict the client.
+ */
+ @Override
+ public String toString() {
+ return "InformerClassifier[client="
+ + client
+ + " ("
+ + masterUrl()
+ + "), labelSelector="
+ + labelSelector
+ + ", shardSelector="
+ + shardSelector
+ + ", namespaceIdentifier="
+ + namespaceIdentifier
+ + ", resourceClass="
+ + (resourceClass != null ? resourceClass.getName() : null)
+ + ", groupVersionKind="
+ + groupVersionKind
+ + ", fieldSelector="
+ + fieldSelector
+ + ", informerListLimit="
+ + informerListLimit
+ + ", itemStore="
+ + itemStore
+ + "]";
+ }
+
+ private String masterUrl() {
+ if (client == null || client.getConfiguration() == null) {
+ return null;
+ }
+ return client.getConfiguration().getMasterUrl();
+ }
+
+ /**
+ * 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..404f14b5f4
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java
@@ -0,0 +1,98 @@
+/*
+ * 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.informers.SharedIndexInformer;
+import io.javaoperatorsdk.operator.api.config.ConfigurationService;
+import io.javaoperatorsdk.operator.api.reconciler.Experimental;
+
+/**
+ * The contract consumed by the event sources. Implementations must extend {@link
+ * AbstractInformerPool} — that is the type the configuration API accepts — which additionally
+ * handles informer creation, startup and the {@link ConfigurationService} injection.
+ */
+@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 {
+
+ /**
+ * The informer backing the event source identified by {@code controllerName} and {@code name}: a
+ * sharing pool returns the existing informer for an equal {@link InformerClassifier} if there is
+ * one and creates it otherwise, a non-sharing pool always creates a dedicated one. A newly
+ * created informer is created from the classifier's {@link InformerClassifier#client()}, which is
+ * part of the classifier's identity precisely so that a shared informer is only ever handed to
+ * event sources watching through that same client.
+ *
+ * The returned informer is not started, callers are expected to call {@link
+ * #start(SharedIndexInformer, InformerClassifier)} afterwards. When joining an already running
+ * shared informer it may however be started and hold a populated cache already; handlers
+ * registered on it still receive the cache contents, so callers must not replay those themselves.
+ *
+ *
This registers the caller as a user of the informer and must therefore be paired with
+ * exactly one {@link #releaseInformer(String, String, InformerClassifier)} for the same
+ * controller name, event source name and classifier. Requesting an informer twice for the same
+ * combination without releasing it in between is a programming error: a sharing pool would count
+ * the caller twice and consequently never stop the informer, which is why {@link
+ * NonSharingInformerPool} rejects it outright.
+ */
+ SharedIndexInformer getInformer(
+ String controllerName, String name, InformerClassifier classifier);
+
+ /**
+ * 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)} returns; the pool
+ * itself only registers/reference-counts the informer and does not block on cache sync
+ * internally.
+ */
+ void start(
+ SharedIndexInformer informer, InformerClassifier classifier);
+
+ /**
+ * Signals that the identified user (controller + event source name) no longer needs the informer
+ * for the given classifier. A sharing pool only stops the informer once its last user has
+ * released it, a non-sharing pool stops it right away.
+ *
+ * The informer is returned in either case, even when it is left running for the remaining
+ * users, since the caller still has to remove its own event handler from it. Callers must not
+ * assume the returned informer is stopped, and must not stop it themselves.
+ *
+ * @return the released informer, or empty if the pool holds none for this user and classifier
+ */
+ Optional> releaseInformer(
+ String controllerName, String name, InformerClassifier classifier);
+
+ /**
+ * Binds this pool to the {@link ConfigurationService} it belongs to. Called by the framework when
+ * the pool is resolved from that configuration service, before the pool is used; users are not
+ * expected to call it themselves.
+ *
+ * The pool needs the configuration service to create and start informers: the {@link
+ * ConfigurationService#cacheSyncTimeout()} to wait for, whether to {@link
+ * ConfigurationService#stopOnInformerErrorDuringStartup()}, and the {@link
+ * ConfigurationService#getInformerStoppedHandler()} to hook up.
+ *
+ *
Injecting it here, rather than requiring it as a constructor argument, is what keeps
+ * creating a pool a plain {@code new NonSharingInformerPool()} for users configuring one through
+ * {@link io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider#withInformerPool}.
+ * A pool instance therefore belongs to exactly one configuration service.
+ */
+ void setConfigurationService(ConfigurationService configurationService);
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPool.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPool.java
new file mode 100644
index 0000000000..da7ac06ab6
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPool.java
@@ -0,0 +1,88 @@
+/*
+ * 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.informers.SharedIndexInformer;
+import io.javaoperatorsdk.operator.OperatorException;
+
+@SuppressWarnings({"unchecked", "rawtypes"})
+public class NonSharingInformerPool extends AbstractInformerPool {
+
+ private static final Logger log = LoggerFactory.getLogger(NonSharingInformerPool.class);
+
+ private final Map informers = new ConcurrentHashMap();
+
+ @Override
+ public synchronized SharedIndexInformer getInformer(
+ String controllerName, String name, InformerClassifier classifier) {
+ 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);
+ informers.put(key, 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). */
+ 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 extends HasMetadata> 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/polling/PerResourcePollingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java
index 886c0ecb05..1ab750d8f0 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java
@@ -75,8 +75,9 @@ public PerResourcePollingEventSource(
private Set getAndCacheResource(P primary, boolean fromGetter) {
var values = resourceFetcher.fetchResources(primary);
- handleResources(ResourceID.fromResource(primary), values, !fromGetter);
- fetchedForPrimaries.add(ResourceID.fromResource(primary));
+ var primaryID = ResourceID.fromResource(primary);
+ handleResources(primaryID, values, !fromGetter);
+ fetchedForPrimaries.add(primaryID);
return values;
}
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/api/config/InformerConfigurationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java
index 95b8465706..16e5ab578b 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java
@@ -16,11 +16,13 @@
package io.javaoperatorsdk.operator.api.config;
import java.util.Collections;
+import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.javaoperatorsdk.operator.api.config.informer.FieldSelector;
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Constants;
@@ -79,6 +81,37 @@ void nullShardSelectorByDefault() {
assertNull(informerConfig.getShardSelector());
}
+ @Test
+ void nullFieldSelectorByDefault() {
+ final var informerConfig = InformerConfiguration.builder(ConfigMap.class).build();
+ assertNull(informerConfig.getFieldSelector());
+ }
+
+ @Test
+ void emptyFieldSelectorIsNormalizedToNoFieldSelector() {
+ // the annotation path always builds a FieldSelector (@Informer#fieldSelector defaults to {})
+ // while the programmatic path leaves it null. An empty selector filters nothing, so the two
+ // must not get classifiers that disagree and therefore refuse to share an informer
+ assertNull(
+ InformerConfiguration.builder(ConfigMap.class)
+ .withFieldSelector(new FieldSelector(List.of()))
+ .build()
+ .getFieldSelector());
+ assertNull(
+ InformerConfiguration.builder(ConfigMap.class)
+ .withFieldSelector(new FieldSelector())
+ .build()
+ .getFieldSelector());
+ }
+
+ @Test
+ void fieldSelectorIsSetOnBuilderWhenNotEmpty() {
+ final var fieldSelector = new FieldSelector(new FieldSelector.Field("metadata.name", "foo"));
+ final var informerConfig =
+ InformerConfiguration.builder(ConfigMap.class).withFieldSelector(fieldSelector).build();
+ assertEquals(fieldSelector, informerConfig.getFieldSelector());
+ }
+
@Test
void shardSelectorIsSetOnBuilder() {
final var informerConfig =
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/GroupVersionKindTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/GroupVersionKindTest.java
index 9874740ae4..8324748207 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/GroupVersionKindTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/GroupVersionKindTest.java
@@ -96,6 +96,18 @@ void pluralShouldOverrideDefaultComputedVersionIfProvided() {
assertThat(gvk.hashCode()).isNotEqualTo(original.hashCode());
}
+ @Test
+ void comparingAnExplicitPluralWithAnUnspecifiedOneIsSymmetricAndDoesNotThrow() {
+ final var original = new GroupVersionKind("josdk.io", "v1", "MyKind");
+ final var withPlural = GroupVersionKindPlural.gvkWithPlural(original, "MyPlural");
+ final var withoutPlural = GroupVersionKindPlural.gvkWithPlural(original, null);
+
+ // an unspecified plural is not a wildcard: it carries no plural form, just like the plain
+ // GroupVersionKind it compares equal to
+ assertThat(withPlural).isNotEqualTo(withoutPlural);
+ assertThat(withoutPlural).isNotEqualTo(withPlural);
+ }
+
@Test
void equals() {
final var original = new GroupVersionKind("josdk.io", "v1", "MyKind");
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverterTest.java
new file mode 100644
index 0000000000..2cc2a37721
--- /dev/null
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverterTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.dependent.kubernetes;
+
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
+import io.javaoperatorsdk.operator.api.config.ConfigurationService;
+import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Focused unit test for the {@code detectApiVersionChange} wiring performed by {@link
+ * KubernetesDependentConverter}, independent of the shared, process-wide {@link
+ * io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver} state
+ * that other tests in this module mutate.
+ */
+class KubernetesDependentConverterTest {
+
+ private final KubernetesDependentConverter converter =
+ new KubernetesDependentConverter<>();
+
+ @Test
+ void detectApiVersionChangeDefaultsToFalseWhenAnnotationAbsent() {
+ var config =
+ converter.configFrom(null, spec(PlainWidgetDependentResource.class), controllerConfig());
+
+ assertThat(config.detectApiVersionChange()).isFalse();
+ }
+
+ @Test
+ void detectApiVersionChangeDefaultsToFalseWhenNotSetOnAnnotation() {
+ var annotation = PlainWidgetDependentResource.class.getAnnotation(KubernetesDependent.class);
+ var config =
+ converter.configFrom(
+ annotation, spec(PlainWidgetDependentResource.class), controllerConfig());
+
+ assertThat(config.detectApiVersionChange()).isFalse();
+ }
+
+ @Test
+ void detectApiVersionChangeCanBeEnabledViaAnnotation() {
+ var annotation =
+ ApiVersionAwareWidgetDependentResource.class.getAnnotation(KubernetesDependent.class);
+ var config =
+ converter.configFrom(
+ annotation, spec(ApiVersionAwareWidgetDependentResource.class), controllerConfig());
+
+ assertThat(config.detectApiVersionChange()).isTrue();
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static DependentResourceSpec<
+ GenericKubernetesResource,
+ ConfigMap,
+ KubernetesDependentResourceConfig>
+ spec(
+ Class extends KubernetesDependentResource>
+ dependentResourceClass) {
+ return new DependentResourceSpec(
+ dependentResourceClass, "test", Set.of(), null, null, null, null, null);
+ }
+
+ private static ControllerConfiguration controllerConfig() {
+ ControllerConfiguration controllerConfig = mock();
+ when(controllerConfig.getName()).thenReturn("test-reconciler");
+ ConfigurationService configurationService = mock();
+ when(configurationService.dependentResourceFactory())
+ .thenReturn(DependentResourceFactory.DEFAULT);
+ when(controllerConfig.getConfigurationService()).thenReturn(configurationService);
+ return controllerConfig;
+ }
+
+ @KubernetesDependent
+ static class PlainWidgetDependentResource
+ extends KubernetesDependentResource
+ implements GarbageCollected {
+ public PlainWidgetDependentResource() {
+ super(GenericKubernetesResource.class, null);
+ }
+ }
+
+ @KubernetesDependent(detectApiVersionChange = true)
+ static class ApiVersionAwareWidgetDependentResource
+ extends KubernetesDependentResource
+ implements GarbageCollected {
+ public ApiVersionAwareWidgetDependentResource() {
+ super(GenericKubernetesResource.class, null);
+ }
+ }
+}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceApiVersionChangeTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceApiVersionChangeTest.java
new file mode 100644
index 0000000000..8c7bb0502a
--- /dev/null
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceApiVersionChangeTest.java
@@ -0,0 +1,343 @@
+/*
+ * 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.dependent.kubernetes;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import io.fabric8.kubernetes.api.model.FieldsV1;
+import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ManagedFieldsEntry;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.javaoperatorsdk.operator.MockKubernetesClient;
+import io.javaoperatorsdk.operator.api.config.ConfigurationService;
+import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies the opt-in behavior enabled by {@link
+ * KubernetesDependentResourceConfig#detectApiVersionChange()}: a dependent resource is considered
+ * mismatched when the API version marker last applied by the operator differs from the one it would
+ * currently apply, without causing updates on every reconciliation once the marker is up-to-date.
+ */
+class KubernetesDependentResourceApiVersionChangeTest {
+
+ private static final String FIELD_MANAGER = "controller";
+ private static final String OLD_API_VERSION = "example.com/v1alpha1";
+ private static final String NEW_API_VERSION = "example.com/v1";
+
+ @Test
+ void featureDisabledByDefaultDoesNotAddMarker() {
+ var dr = newDependentResource(false);
+ var context = context(false);
+
+ var actual = widget(NEW_API_VERSION, null, 3);
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched()).isTrue();
+ assertThat(desired.getMetadata().getAnnotations())
+ .doesNotContainKey(KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY);
+ }
+
+ @Test
+ void featureDisabledIgnoresPreExistingMarkerMismatch() {
+ var dr = newDependentResource(false);
+ var context = context(false);
+
+ var actual =
+ widget(
+ NEW_API_VERSION,
+ Map.of(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY,
+ OLD_API_VERSION),
+ 3);
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("Disabled feature must reproduce current, unaffected behavior")
+ .isTrue();
+ }
+
+ @Test
+ void nonSSA_missingMarkerCausesMismatchAndMarksDesired() {
+ var dr = newDependentResource(true);
+ var context = context(false);
+
+ var actual = widget(NEW_API_VERSION, null, 3);
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("A resource with no marker annotation must be considered mismatched")
+ .isFalse();
+ assertThat(desired.getMetadata().getAnnotations())
+ .containsEntry(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION);
+ }
+
+ @Test
+ void nonSSA_matchingMarkerMatches() {
+ var dr = newDependentResource(true);
+ var context = context(false);
+
+ var actual =
+ widget(
+ NEW_API_VERSION,
+ Map.of(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY,
+ NEW_API_VERSION),
+ 3);
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched()).isTrue();
+ }
+
+ @Test
+ void nonSSA_staleMarkerCausesMismatch() {
+ var dr = newDependentResource(true);
+ var context = context(false);
+
+ var actual =
+ widget(
+ NEW_API_VERSION,
+ Map.of(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY,
+ OLD_API_VERSION),
+ 3);
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("A stale marker must cause an update to be requested")
+ .isFalse();
+ assertThat(desired.getMetadata().getAnnotations())
+ .withFailMessage("The desired resource must be marked with the new API version")
+ .containsEntry(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION);
+ }
+
+ @Test
+ void nonSSA_matchingMarkerStillDetectsUnrelatedSpecChanges() {
+ var dr = newDependentResource(true);
+ var context = context(false);
+
+ var actual =
+ widget(
+ NEW_API_VERSION,
+ Map.of(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY,
+ NEW_API_VERSION),
+ 3);
+ var desired = widget(NEW_API_VERSION, null, 4);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("Normal spec matching must remain intact regardless of the marker")
+ .isFalse();
+ }
+
+ @Test
+ void nonSSA_missingApiVersionOnDesiredIsHandledSafely() {
+ var dr = newDependentResource(true);
+ var context = context(false);
+
+ var actual = widget(NEW_API_VERSION, null, 3);
+ var desired = widget(null, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("A null desired API version must not prevent normal matching")
+ .isTrue();
+ assertThat(desired.getMetadata().getAnnotations())
+ .doesNotContainKey(KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY);
+ }
+
+ @Test
+ void nonSSA_preservesExistingAnnotationsWhenMarkingEvenIfImmutable() {
+ var dr = newDependentResource(true);
+ var context = context(false);
+
+ var actual = widget(NEW_API_VERSION, null, 3);
+ var desired = widget(NEW_API_VERSION, null, 3);
+ // simulate a desired resource whose annotations map is immutable, as returned by Map.of(...)
+ desired.getMetadata().setAnnotations(Map.of("user.example.com/owner", "team-a"));
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("A missing marker must still cause a mismatch")
+ .isFalse();
+ assertThat(desired.getMetadata().getAnnotations())
+ .withFailMessage("Existing annotations must be preserved alongside the new marker")
+ .containsEntry("user.example.com/owner", "team-a")
+ .containsEntry(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION);
+ }
+
+ @Test
+ void ssa_missingMarkerCausesMismatchAndMarksDesired() {
+ var dr = newDependentResource(true);
+ var context = context(true);
+
+ var actual = widget(NEW_API_VERSION, null, 3);
+ actual.getMetadata().setManagedFields(List.of(managedFieldsEntry(false)));
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("A resource applied before the marker existed must be updated once")
+ .isFalse();
+ assertThat(desired.getMetadata().getAnnotations())
+ .containsEntry(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION);
+ }
+
+ @Test
+ void ssa_matchingMarkerMatches() {
+ var dr = newDependentResource(true);
+ var context = context(true);
+
+ var actual =
+ widget(
+ NEW_API_VERSION,
+ Map.of(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY,
+ NEW_API_VERSION),
+ 3);
+ actual.getMetadata().setManagedFields(List.of(managedFieldsEntry(true)));
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("No further update should be requested once the marker is up-to-date")
+ .isTrue();
+ }
+
+ @Test
+ void ssa_staleMarkerCausesMismatch() {
+ var dr = newDependentResource(true);
+ var context = context(true);
+
+ var actual =
+ widget(
+ NEW_API_VERSION,
+ Map.of(
+ KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY,
+ OLD_API_VERSION),
+ 3);
+ actual.getMetadata().setManagedFields(List.of(managedFieldsEntry(true)));
+ var desired = widget(NEW_API_VERSION, null, 3);
+
+ var result = dr.match(actual, desired, primary(), context);
+
+ assertThat(result.matched())
+ .withFailMessage("A stale marker recorded via SSA must still cause a mismatch")
+ .isFalse();
+ }
+
+ private static WidgetDependentResourceForTest newDependentResource(
+ boolean detectApiVersionChange) {
+ var dr = new WidgetDependentResourceForTest();
+ dr.configureWith(
+ new KubernetesDependentResourceConfigBuilder()
+ .withDetectApiVersionChange(detectApiVersionChange)
+ .build());
+ return dr;
+ }
+
+ private static HasMetadata primary() {
+ return mock();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Context context(boolean useSSA) {
+ Context context = mock();
+ var client = MockKubernetesClient.client(HasMetadata.class);
+ when(context.getClient()).thenReturn(client);
+
+ var configurationService = mock(ConfigurationService.class);
+ when(configurationService.shouldUseSSA(any(), any(), any())).thenReturn(useSSA);
+ ControllerConfiguration controllerConfiguration = mock();
+ when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService);
+ when(controllerConfiguration.fieldManager()).thenReturn(FIELD_MANAGER);
+ when(context.getControllerConfiguration()).thenReturn(controllerConfiguration);
+ return context;
+ }
+
+ private static GenericKubernetesResource widget(
+ String apiVersion, Map annotations, int specSize) {
+ var resource = new GenericKubernetesResource();
+ resource.setApiVersion(apiVersion);
+ resource.setKind("Widget");
+ var metadataBuilder = new ObjectMetaBuilder().withName("test").withNamespace("default");
+ if (annotations != null) {
+ metadataBuilder.withAnnotations(annotations);
+ }
+ resource.setMetadata(metadataBuilder.build());
+ resource.setAdditionalProperty("spec", Map.of("size", specSize));
+ return resource;
+ }
+
+ private static ManagedFieldsEntry managedFieldsEntry(boolean managesAnnotation) {
+ Map fields = new LinkedHashMap<>();
+ fields.put("f:spec", Map.of("f:size", Map.of()));
+ if (managesAnnotation) {
+ fields.put(
+ "f:metadata",
+ Map.of(
+ "f:annotations",
+ Map.of(
+ "f:" + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY,
+ Map.of())));
+ }
+ var fieldsV1 = new FieldsV1();
+ fieldsV1.setAdditionalProperties(fields);
+
+ var entry = new ManagedFieldsEntry();
+ entry.setManager(FIELD_MANAGER);
+ entry.setOperation("Apply");
+ entry.setFieldsV1(fieldsV1);
+ return entry;
+ }
+
+ private static class WidgetDependentResourceForTest
+ extends KubernetesDependentResource {
+ public WidgetDependentResourceForTest() {
+ super(GenericKubernetesResource.class, null);
+ }
+ }
+}
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..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
@@ -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;
@@ -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/ResourceStateManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java
index d480dd06f8..8ac3be8c35 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java
@@ -27,7 +27,7 @@
class ResourceStateManagerTest {
- private final ResourceStateManager manager = new ResourceStateManager();
+ private final ResourceStateManager manager = new ResourceStateManager(false);
private final ResourceID sampleResourceID = new ResourceID("test-name");
private final ResourceID sampleResourceID2 = new ResourceID("test-name2");
private ResourceState state;
@@ -49,7 +49,7 @@ public void returnsNoEventPresentIfNotMarkedYet() {
@Test
public void marksEvent() {
- state.markEventReceived(false);
+ state.markEventReceived();
assertThat(state.eventPresent()).isTrue();
assertThat(state.deleteEventPresent()).isFalse();
@@ -65,7 +65,7 @@ public void marksDeleteEvent() {
@Test
public void afterDeleteEventMarkEventIsNotRelevant() {
- state.markEventReceived(false);
+ state.markEventReceived();
state.markDeleteEventReceived(TestUtils.testCustomResource(), true);
@@ -75,7 +75,7 @@ public void afterDeleteEventMarkEventIsNotRelevant() {
@Test
public void cleansUp() {
- state.markEventReceived(false);
+ state.markEventReceived();
state.markDeleteEventReceived(TestUtils.testCustomResource(), true);
manager.remove(sampleResourceID);
@@ -91,15 +91,15 @@ public void cannotMarkEventAfterDeleteEventReceived() {
IllegalStateException.class,
() -> {
state.markDeleteEventReceived(TestUtils.testCustomResource(), true);
- state.markEventReceived(false);
+ state.markEventReceived();
});
}
@Test
public void listsResourceIDSWithEventsPresent() {
- state.markEventReceived(false);
- state2.markEventReceived(false);
- state.unMarkEventReceived(false);
+ state.markEventReceived();
+ state2.markEventReceived();
+ state.unMarkEventReceived();
var res = manager.resourcesWithEventPresent();
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java
index efd48bb6a2..a5e1b8edc2 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java
@@ -15,6 +15,7 @@
*/
package io.javaoperatorsdk.operator.processing.event.source;
+import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
@@ -211,6 +212,167 @@ void genericFilteringEvents() {
verify(eventHandler, times(0)).handleEvent(any());
}
+ @Test
+ void retainsRecentlyCreatedResourceMissingFromUpdate() {
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceCreate(primaryID1(), testResource2());
+
+ // the update was created before the resource, thus does not contain it yet
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ assertThat(source.getSecondaryResources(primaryID1()))
+ .containsExactlyInAnyOrder(testResource1(), testResource2());
+ // no event for the retained resource, only the initial add event
+ verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
+ }
+
+ @Test
+ void retainsRecentlyCreatedResourceOnlyForASingleUpdate() {
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceCreate(primaryID1(), testResource2());
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ // this update is created after the resource, so it is really deleted meanwhile
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
+ verify(eventHandler, times(2)).handleEvent(new Event(primaryID1()));
+ }
+
+ @Test
+ void doesNotRetainRecentlyCreatedResourceDeletedBeforeTheUpdate() {
+ source.handleRecentResourceCreate(primaryID1(), testResource2());
+ source.handleDelete(primaryID1(), testResource2());
+
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
+ }
+
+ @Test
+ void retainsRecentlyCreatedResourceMissingFromWholeCacheUpdate() {
+ source.handleRecentResourceCreate(primaryID1(), testResource1());
+
+ source.handleResources(Map.of());
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
+
+ source.handleResources(Map.of());
+
+ assertThat(source.getSecondaryResources(primaryID1())).isEmpty();
+ }
+
+ @Test
+ void retainsRecentlyUpdatedResourceMissingFromUpdate() {
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+
+ // the update was created before the resource was updated, thus still contains the old state
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(changedTestResource1());
+ // no event for the retained resource, only the initial add event
+ verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
+ }
+
+ @Test
+ void retainsRecentlyUpdatedResourceOnlyForASingleUpdate() {
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ // this update is created after the resource was updated, so it was really changed meanwhile
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
+ verify(eventHandler, times(2)).handleEvent(new Event(primaryID1()));
+ }
+
+ @Test
+ void doesNotRetainRecentlyUpdatedResourceChangedOutsideOfTheReconciler() {
+ var externallyChanged = testResource1().setValue("externallyChangedValue");
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+
+ source.handleResources(primaryID1(), Set.of(externallyChanged));
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(externallyChanged);
+ }
+
+ @Test
+ void retainsRecentlyUpdatedResourceInWholeCacheUpdate() {
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+
+ source.handleResources(Map.of(primaryID1(), Set.of(testResource1())));
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(changedTestResource1());
+ }
+
+ @Test
+ void retainsResourceUpdatedTwiceIfUpdateContainsTheStateBeforeBothWrites() {
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+ source.handleRecentResourceUpdate(
+ primaryID1(), changedTwiceTestResource1(), changedTestResource1());
+
+ // the update was created before both writes, thus contains the state before the first one
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+
+ assertThat(source.getSecondaryResources(primaryID1()))
+ .containsExactly(changedTwiceTestResource1());
+ // no event for the retained resource, only the initial add event
+ verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
+ }
+
+ @Test
+ void retainsResourceUpdatedTwiceIfUpdateContainsTheStateBetweenTheWrites() {
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+ source.handleRecentResourceUpdate(
+ primaryID1(), changedTwiceTestResource1(), changedTestResource1());
+
+ // the update was created between the two writes, thus contains the intermediate state
+ source.handleResources(primaryID1(), Set.of(changedTestResource1()));
+
+ assertThat(source.getSecondaryResources(primaryID1()))
+ .containsExactly(changedTwiceTestResource1());
+ verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
+ }
+
+ @Test
+ void retainsRecentlyCreatedAndThenUpdatedResourceMissingFromUpdate() {
+ source.handleRecentResourceCreate(primaryID1(), testResource1());
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+
+ // the update was created before both writes, thus does not contain the resource yet
+ source.handleResources(primaryID1(), Set.of(testResource2()));
+
+ assertThat(source.getSecondaryResources(primaryID1()))
+ .containsExactlyInAnyOrder(changedTestResource1(), testResource2());
+ }
+
+ @Test
+ void doesNotRetainResourceUpdatedTwiceIfChangedOutsideOfTheReconciler() {
+ var externallyChanged = testResource1().setValue("externallyChangedValue");
+ source.handleResources(primaryID1(), Set.of(testResource1()));
+ source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
+ source.handleRecentResourceUpdate(
+ primaryID1(), changedTwiceTestResource1(), changedTestResource1());
+
+ source.handleResources(primaryID1(), Set.of(externallyChanged));
+
+ assertThat(source.getSecondaryResources(primaryID1())).containsExactly(externallyChanged);
+ }
+
+ private static SampleExternalResource changedTestResource1() {
+ return testResource1().setValue("changedValue");
+ }
+
+ private static SampleExternalResource changedTwiceTestResource1() {
+ return testResource1().setValue("changedValueAgain");
+ }
+
@Test
void getSecondaryResourcesReturnsASnapshotNotALiveView() {
source.handleResources(primaryID1(), Set.of(testResource1()));
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/InformerManagerConcurrentReleaseTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManagerConcurrentReleaseTest.java
new file mode 100644
index 0000000000..e8801781db
--- /dev/null
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManagerConcurrentReleaseTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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;
+
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.informers.ResourceEventHandler;
+import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
+import io.javaoperatorsdk.operator.MockKubernetesClient;
+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.informer.InformerConfiguration;
+import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration;
+import io.javaoperatorsdk.operator.processing.event.source.informer.pool.DefaultInformerPool;
+import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerClassifier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A pooled informer is reference counted, so releasing the same namespace twice consumes a
+ * reference another controller still holds. {@link InformerManager#stop()} and {@link
+ * InformerManager#changeNamespaces(Set)} can run concurrently, so removing the source from the
+ * manager has to be what claims the right to release it.
+ */
+@SuppressWarnings({"rawtypes", "unchecked"})
+class InformerManagerConcurrentReleaseTest {
+
+ private static final String NAMESPACE = "ns1";
+
+ private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class);
+ private final LatchingInformerPool pool = new LatchingInformerPool();
+ private final InformerEventSourceConfiguration configuration =
+ mock(InformerEventSourceConfiguration.class);
+ private final ResourceEventHandler eventHandler = mock(ResourceEventHandler.class);
+
+ @BeforeEach
+ void setup() {
+ final var informerConfig = mock(InformerConfiguration.class);
+ when(informerConfig.getEffectiveNamespaces(any())).thenReturn(Set.of(NAMESPACE));
+ when(informerConfig.getInformerListLimit()).thenReturn(null);
+ when(configuration.getInformerConfig()).thenReturn(informerConfig);
+ when(configuration.getResourceClass()).thenReturn(Deployment.class);
+ }
+
+ @Test
+ void concurrentStopAndNamespaceChangeReleaseTheInformerOnlyOnce() throws Exception {
+ var manager =
+ new InformerManager