diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml index b0d0df6698..bcd6c2e743 100644 --- a/bootstrapper-maven-plugin/pom.xml +++ b/bootstrapper-maven-plugin/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.2-SNAPSHOT + 999-SNAPSHOT bootstrapper diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index 6196e2bfa1..be70ab9a2e 100644 --- a/caffeine-bounded-cache-support/pom.xml +++ b/caffeine-bounded-cache-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.2-SNAPSHOT + 999-SNAPSHOT caffeine-bounded-cache-support diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java index 292ddb975b..89bf3ba05a 100644 --- a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java @@ -96,15 +96,14 @@ public List> 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 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> 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 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 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 primaryResourceType; + private final SecondaryToPrimaryMapper delegate; public SecondaryToPrimaryFromDefaultAnnotation( Class 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 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 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 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: + * + *

+ * + *

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 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> + 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>( + configuration, eventHandler, "event-source"); + manager.setControllerConfiguration(controllerConfiguration()); + manager.start(); + // a second controller shares the very same informer and never releases it, so the pool has to + // keep it running no matter how the manager below is torn down + var informer = pool.getInformer("other-controller", "other-es", classifier()); + + // drop the only watched namespace on one thread; the pool blocks inside releaseInformer, which + // is the window in which stop() used to see the already-released source and release it again + var namespaceChange = new Thread(() -> manager.changeNamespaces(Set.of())); + namespaceChange.start(); + assertThat(pool.enteredRelease.await(5, TimeUnit.SECONDS)).isTrue(); + + manager.stop(); + + pool.proceed.countDown(); + namespaceChange.join(TimeUnit.SECONDS.toMillis(5)); + + assertThat(pool.releaseCount.get()) + .as("the same namespace must not be released twice") + .isEqualTo(1); + verify(informer, never()).stop(); + } + + private ControllerConfiguration controllerConfiguration() { + ConfigurationService configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + o -> o.withKubernetesClient(clientMock).withInformerPool(pool)); + var controllerConfiguration = mock(ControllerConfiguration.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + when(controllerConfiguration.getName()).thenReturn("controller"); + return controllerConfiguration; + } + + /** Has to match what the manager builds for {@link #NAMESPACE} so it hits the same pool entry. */ + private InformerClassifier classifier() { + return new InformerClassifier<>( + clientMock, null, null, NAMESPACE, Deployment.class, null, null, null, null); + } + + /** Blocks inside the first release so the two teardown paths can be interleaved on purpose. */ + private static class LatchingInformerPool extends DefaultInformerPool { + + private final CountDownLatch enteredRelease = new CountDownLatch(1); + private final CountDownLatch proceed = new CountDownLatch(1); + private final AtomicInteger releaseCount = new AtomicInteger(); + private final AtomicBoolean blockNextRelease = new AtomicBoolean(true); + + @Override + public Optional> releaseInformer( + String controllerName, String name, InformerClassifier classifier) { + releaseCount.incrementAndGet(); + // deliberately blocking before delegating: releaseInformer is synchronized, so waiting inside + // it would just serialize the two threads instead of interleaving them + if (blockNextRelease.compareAndSet(true, false)) { + enteredRelease.countDown(); + try { + proceed.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return super.releaseInformer(controllerName, name, classifier); + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapperTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapperTest.java new file mode 100644 index 0000000000..e175074125 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapperTest.java @@ -0,0 +1,129 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerClassifier; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The informer behind a wrapper can be shared by event sources of several controllers, while its + * indexer is a single namespace of index names. The wrapper therefore qualifies index names with + * the event source that registered them, which only works as long as registration, lookup and + * removal all apply the same qualification. + */ +class InformerWrapperTest { + + private static final String INDEX_NAME = "my-index"; + private static final String INDEX_KEY = "key"; + + @SuppressWarnings("unchecked") + private final Cache indexer = mock(Cache.class); + + @Test + void indexNamesAreQualifiedOnRegistrationAndOnLookup() { + var wrapper = wrapper("controller", "event-source"); + + wrapper.addIndexers(Map.of(INDEX_NAME, r -> List.of(INDEX_KEY))); + wrapper.byIndex(INDEX_NAME, INDEX_KEY); + + var registered = registeredNames(); + // the caller's name is not what reaches the informer... + assertThat(registered).hasSize(1).allSatisfy(name -> assertThat(name).isNotEqualTo(INDEX_NAME)); + // ...but it is still recognizable in it, so that client side errors stay diagnosable + assertThat(registered) + .allSatisfy( + name -> + assertThat(name) + .contains("controller") + .contains("event-source") + .endsWith(INDEX_NAME)); + // and lookup asks for exactly the name that was registered + verify(indexer).byIndex(eq(registered.get(0)), eq(INDEX_KEY)); + } + + @Test + void twoEventSourcesRegisterTheSameIndexNameUnderDistinctQualifiedNames() { + // this is what keeps the client from rejecting the second one with an "Indexer conflict", and + // what keeps either of them from reading the other's index + wrapper("controller-1", "event-source") + .addIndexers(Map.of(INDEX_NAME, r -> List.of(INDEX_KEY))); + wrapper("controller-2", "event-source") + .addIndexers(Map.of(INDEX_NAME, r -> List.of(INDEX_KEY))); + wrapper("controller-1", "other-event-source") + .addIndexers(Map.of(INDEX_NAME, r -> List.of(INDEX_KEY))); + + assertThat(registeredNames()).hasSize(3).doesNotHaveDuplicates(); + } + + @Test + void removeIndexersDropsExactlyTheNamesThisWrapperRegistered() { + var wrapper = wrapper("controller", "event-source"); + wrapper.addIndexers(Map.of(INDEX_NAME, r -> List.of(INDEX_KEY))); + var registered = registeredNames().get(0); + + wrapper.removeIndexers(); + + verify(indexer).removeIndexer(registered); + } + + @Test + void removeIndexersIsANoopWithoutRegisteredIndexers() { + wrapper("controller", "event-source").removeIndexers(); + + verify(indexer, never()).removeIndexer(any()); + } + + @SuppressWarnings("unchecked") + private InformerWrapper wrapper(String controller, String eventSource) { + SharedIndexInformer informer = mock(SharedIndexInformer.class); + when(informer.getStore()).thenReturn(indexer); + when(informer.getIndexer()).thenReturn(indexer); + return new InformerWrapper<>( + informer, + "default", + new InformerClassifier<>( + null, null, null, "default", TestCustomResource.class, null, null, null, null), + controller, + eventSource); + } + + @SuppressWarnings("unchecked") + private List registeredNames() { + var captor = ArgumentCaptor.forClass(Map.class); + verify(indexer, atLeastOnce()).addIndexers(captor.capture()); + return captor.getAllValues().stream() + .flatMap(m -> m.keySet().stream()) + .map(String::valueOf) + .toList(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceNamespaceChangeTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceNamespaceChangeTest.java new file mode 100644 index 0000000000..a1ee558036 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceNamespaceChangeTest.java @@ -0,0 +1,132 @@ +/* + * 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.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.fabric8.kubernetes.client.informers.cache.Cache; +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.EventHandler; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.DefaultInformerPool; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +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; + +/** + * A namespace change acquires and starts pooled informers, so it must not happen on an event source + * that is no longer running: {@link ManagedInformerEventSource#stop()} short-circuits on a + * non-running event source, which would leave those informers referenced with nothing left to + * release them. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class ManagedInformerEventSourceNamespaceChangeTest { + + private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); + private final DefaultInformerPool pool = new DefaultInformerPool(); + private final InformerEventSourceConfiguration configuration = + mock(InformerEventSourceConfiguration.class); + + @BeforeEach + void setup() { + final var informerConfig = mock(InformerConfiguration.class); + when(informerConfig.getEffectiveNamespaces(any())).thenReturn(Set.of("ns1")); + when(informerConfig.getInformerListLimit()).thenReturn(null); + when(configuration.getInformerConfig()).thenReturn(informerConfig); + when(configuration.getResourceClass()).thenReturn(Deployment.class); + when(configuration.followControllerNamespaceChanges()).thenReturn(true); + final var secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) + .thenReturn(Set.of(new ResourceID("name", "ns1"))); + when(configuration.getSecondaryToPrimaryMapper()).thenReturn(secondaryToPrimaryMapper); + stubInformerStore(); + } + + @Test + void namespaceChangeOnAStoppedEventSourceAcquiresNoInformer() { + var eventSource = buildEventSource(); + eventSource.start(); + assertThat(pool.numberOfInformersForResource(Deployment.class)).isEqualTo(1); + eventSource.stop(); + assertThat(pool.numberOfInformersForResource(Deployment.class)).isZero(); + + eventSource.changeNamespaces(Set.of("ns2")); + + assertThat(pool.numberOfInformersForResource(Deployment.class)) + .as("a stopped event source must not acquire informers it can never release") + .isZero(); + } + + @Test + void namespaceChangeIsStillAppliedWhileRunning() { + var eventSource = buildEventSource(); + eventSource.start(); + + eventSource.changeNamespaces(Set.of("ns2")); + + assertThat(pool.numberOfInformersForResource(Deployment.class)).isEqualTo(1); + assertThat(eventSource.manager().isWatchingNamespace("ns2")).isTrue(); + assertThat(eventSource.manager().isWatchingNamespace("ns1")).isFalse(); + + eventSource.stop(); + assertThat(pool.numberOfInformersForResource(Deployment.class)).isZero(); + } + + /** + * A successfully started {@link InformerEventSource} lists its cache to populate the + * primary-to-secondary index, and the mock client leaves the informer's store unstubbed. + */ + private void stubInformerStore() { + SharedIndexInformer informer = + clientMock + .resources(Deployment.class) + .inNamespace("ns1") + .withLabelSelector((String) null) + .withShardSelector(null) + .runnableInformer(0); + when(informer.getStore()).thenReturn(mock(Cache.class)); + } + + private InformerEventSource buildEventSource() { + ConfigurationService configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + o -> o.withKubernetesClient(clientMock).withInformerPool(pool)); + var controllerConfiguration = mock(ControllerConfiguration.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + when(controllerConfiguration.getName()).thenReturn("controller"); + + var eventSource = new InformerEventSource(configuration); + eventSource.setEventHandler(mock(EventHandler.class)); + eventSource.setControllerConfiguration(controllerConfiguration); + return eventSource; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourcePoolIdentityTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourcePoolIdentityTest.java new file mode 100644 index 0000000000..a2d0759fbc --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourcePoolIdentityTest.java @@ -0,0 +1,164 @@ +/* + * 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.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; + +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.SharedIndexInformer; +import io.fabric8.kubernetes.client.informers.cache.Cache; +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.EventHandler; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.DefaultInformerPool; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerClassifier; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +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; + +/** + * The name an event source is known by in the pool decides two things: which pool entry it shares + * (or, with a non-sharing pool, occupies), and the namespace its index names live in on a shared + * informer. It therefore has to identify the event source, which {@link + * InformerConfiguration#getName()} does not: that is {@code null} unless the event source was + * explicitly named, which would collapse every unnamed event source of a controller onto one + * identity. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class ManagedInformerEventSourcePoolIdentityTest { + + private static final String NAMESPACE = "ns1"; + private static final String CONTROLLER = "controller"; + + private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); + private final RecordingInformerPool pool = new RecordingInformerPool(); + + @BeforeEach + void setup() { + SharedIndexInformer informer = + clientMock + .resources(Deployment.class) + .inNamespace(NAMESPACE) + .withLabelSelector((String) null) + .withShardSelector(null) + .runnableInformer(0); + when(informer.getStore()).thenReturn(mock(Cache.class)); + } + + @Test + void anUnnamedEventSourceIsIdentifiedByItsOwnGeneratedName() { + var eventSource = startedEventSource(); + + assertThat(eventSource.name()).isNotNull(); + assertThat(pool.acquiredNames) + .as("the pool has to see the event source's own name, not the unset configured one") + .containsExactly(eventSource.name()); + } + + @Test + void twoUnnamedEventSourcesOfOneControllerAreIdentifiedDistinctly() { + var first = startedEventSource(); + var second = startedEventSource(); + + // identical configuration, so both resolve the same classifier and share one informer: only the + // name keeps them apart, both as pool users and as owners of their index names + assertThat(pool.acquiredNames).doesNotHaveDuplicates(); + assertThat(first.name()).isNotEqualTo(second.name()); + } + + @Test + void releaseUsesTheSameNameAsTheAcquisition() { + var eventSource = startedEventSource(); + + eventSource.stop(); + + // an asymmetry here would leave the pool holding a reference forever + assertThat(pool.releasedNames).isEqualTo(pool.acquiredNames); + } + + private InformerEventSource startedEventSource() { + var configuration = mock(InformerEventSourceConfiguration.class); + var informerConfig = mock(InformerConfiguration.class); + when(informerConfig.getEffectiveNamespaces(any())).thenReturn(Set.of(NAMESPACE)); + when(informerConfig.getInformerListLimit()).thenReturn(null); + // the event source is not named, which is what makes the configured name null + when(informerConfig.getName()).thenReturn(null); + when(configuration.getInformerConfig()).thenReturn(informerConfig); + when(configuration.getResourceClass()).thenReturn(Deployment.class); + var secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) + .thenReturn(Set.of(new ResourceID("name", NAMESPACE))); + when(configuration.getSecondaryToPrimaryMapper()).thenReturn(secondaryToPrimaryMapper); + + var configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + o -> o.withKubernetesClient(clientMock).withInformerPool(pool)); + var controllerConfiguration = mock(ControllerConfiguration.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + when(controllerConfiguration.getName()).thenReturn(CONTROLLER); + + var eventSource = new InformerEventSource(configuration); + eventSource.setEventHandler(mock(EventHandler.class)); + eventSource.setControllerConfiguration(controllerConfiguration); + eventSource.start(); + return eventSource; + } + + /** Records the identities the pool is asked about, and does not start the mocked informers. */ + private static class RecordingInformerPool extends DefaultInformerPool { + + private final List acquiredNames = new CopyOnWriteArrayList<>(); + private final List releasedNames = new CopyOnWriteArrayList<>(); + + @Override + public SharedIndexInformer getInformer( + String controllerName, String name, InformerClassifier classifier) { + acquiredNames.add(name); + return super.getInformer(controllerName, name, classifier); + } + + @Override + public Optional> releaseInformer( + String controllerName, String name, InformerClassifier classifier) { + releasedNames.add(name); + return super.releaseInformer(controllerName, name, classifier); + } + + @Override + public void start( + SharedIndexInformer informer, InformerClassifier classifier) { + // the informers here are mocks, starting them would add nothing + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceStartFailureTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceStartFailureTest.java new file mode 100644 index 0000000000..a4d78ace74 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSourceStartFailureTest.java @@ -0,0 +1,155 @@ +/* + * 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.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +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.SharedIndexInformer; +import io.fabric8.kubernetes.client.informers.cache.Cache; +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.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.processing.event.EventHandler; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.DefaultInformerPool; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerClassifier; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The informers of an event source are acquired from the pool before any of them is started, so a + * failure while starting them has to release everything that was already acquired. Otherwise the + * pooled informer stays referenced forever: {@code start()} never marks the event source running, + * so {@code stop()} silently skips the release. + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +class ManagedInformerEventSourceStartFailureTest { + + private static final Set NAMESPACES = Set.of("ns1", "ns2"); + + private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); + private final FailingOnceInformerPool pool = new FailingOnceInformerPool(); + private final InformerEventSourceConfiguration configuration = + mock(InformerEventSourceConfiguration.class); + + @BeforeEach + void setup() { + final var informerConfig = mock(InformerConfiguration.class); + when(informerConfig.getEffectiveNamespaces(any())).thenReturn(NAMESPACES); + // an unconfigured informer has no list limit, while a plain Long-returning mock would yield 0 + // here and send the pool down the withLimit(...) branch + when(informerConfig.getInformerListLimit()).thenReturn(null); + when(configuration.getInformerConfig()).thenReturn(informerConfig); + when(configuration.getResourceClass()).thenReturn(Deployment.class); + final var secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) + .thenReturn(Set.of(new ResourceID("name", "ns1"))); + when(configuration.getSecondaryToPrimaryMapper()).thenReturn(secondaryToPrimaryMapper); + stubInformerStore(); + } + + /** + * A successfully started {@link InformerEventSource} lists its cache to populate the + * primary-to-secondary index, and the mock client leaves the informer's store unstubbed. + */ + private void stubInformerStore() { + SharedIndexInformer informer = + clientMock + .resources(Deployment.class) + .inNamespace("ns1") + .withLabelSelector((String) null) + .withShardSelector(null) + .runnableInformer(0); + when(informer.getStore()).thenReturn(mock(Cache.class)); + } + + @Test + void releasesAlreadyAcquiredInformersWhenStartupFails() { + var eventSource = buildEventSource(); + + assertThatThrownBy(eventSource::start).isInstanceOf(RuntimeException.class); + + assertThat(eventSource.isRunning()).isFalse(); + assertThat(pool.numberOfInformersForResource(Deployment.class)) + .as("a failed start must not leave informers referenced in the pool") + .isZero(); + } + + @Test + void doesNotAcquireTwiceWhenStartIsRetriedAfterAFailure() { + var eventSource = buildEventSource(); + + assertThatThrownBy(eventSource::start).isInstanceOf(RuntimeException.class); + // a failed event source is started again, e.g. by a subsequent dynamic registration + eventSource.start(); + assertThat(eventSource.isRunning()).isTrue(); + assertThat(pool.numberOfInformersForResource(Deployment.class)).isEqualTo(NAMESPACES.size()); + + eventSource.stop(); + + assertThat(pool.numberOfInformersForResource(Deployment.class)) + .as("the retried start must not have acquired a second reference per informer") + .isZero(); + } + + private InformerEventSource buildEventSource() { + var configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + o -> o.withKubernetesClient(clientMock).withInformerPool(pool)); + var controllerConfiguration = mock(ControllerConfiguration.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + when(controllerConfiguration.getName()).thenReturn("controller"); + + var eventSource = new InformerEventSource(configuration); + eventSource.setEventHandler(mock(EventHandler.class)); + eventSource.setControllerConfiguration(controllerConfiguration); + return eventSource; + } + + /** Fails the very first informer startup, so later attempts succeed. */ + private static class FailingOnceInformerPool extends DefaultInformerPool { + + private final AtomicBoolean failNextStart = new AtomicBoolean(true); + + @Override + public void start( + SharedIndexInformer informer, InformerClassifier classifier) { + if (failNextStart.compareAndSet(true, false)) { + throw new OperatorException("simulated informer startup failure"); + } + // not delegating on purpose: the pool's start is the seam under test, actually starting the + // mocked informer would add nothing + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java new file mode 100644 index 0000000000..4ca4db22bd --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPoolTest.java @@ -0,0 +1,154 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for the reference-counting / sharing behavior of {@link DefaultInformerPool}: a single + * informer is created and shared per classifier, and it is stopped only when the last user releases + * it. + */ +class DefaultInformerPoolTest { + + private static final String CONTROLLER = "controller"; + private static final String ES_NAME = "event-source"; + + private final KubernetesClient client = MockKubernetesClient.client(TestCustomResource.class); + private final DefaultInformerPool pool = new DefaultInformerPool(); + + @BeforeEach + void setup() { + pool.setConfigurationService(new BaseConfigurationService()); + } + + @Test + void sharesSingleInformerForTheSameClassifier() { + var classifier = classifier("default"); + + var first = pool.getInformer(CONTROLLER, ES_NAME, classifier); + var second = pool.getInformer("other-controller", "other-es", classifier); + + assertThat(first).isSameAs(second); + assertThat(pool.size()).isEqualTo(1); + assertThat(pool.numberOfInformersForResource(TestCustomResource.class)).isEqualTo(1); + // the underlying informer must be created exactly once, not once per user + verify(client, times(1)).resources(TestCustomResource.class); + } + + @Test + void createsSeparateInformersForDifferentClassifiers() { + pool.getInformer(CONTROLLER, ES_NAME, classifier("ns1")); + pool.getInformer(CONTROLLER, ES_NAME, classifier("ns2")); + + assertThat(pool.size()).isEqualTo(2); + assertThat(pool.numberOfInformersForResource(TestCustomResource.class)).isEqualTo(2); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void createsSeparateInformersForDifferentClientsWithTheSameApiServerUrl() { + var otherClient = MockKubernetesClient.client(TestCustomResource.class); + // the two clients are indistinguishable by URL, they are two different instances though and may + // well differ in credentials or TLS material, so they must not end up sharing an informer + assertThat(otherClient.getConfiguration().getMasterUrl()).isEqualTo(masterUrl()); + + pool.getInformer(CONTROLLER, ES_NAME, classifier("default")); + pool.getInformer("other-controller", "other-es", classifier(otherClient, "default")); + + assertThat(pool.size()).isEqualTo(2); + verify(client, times(1)).resources(TestCustomResource.class); + verify(otherClient, times(1)).resources(TestCustomResource.class); + } + + @Test + void sharesInformerWhenClassifiersDifferOnlyByListLimit() { + var withLimit100 = + new InformerClassifier<>( + client, null, null, "default", TestCustomResource.class, null, null, 100L, null); + var withLimit200 = + new InformerClassifier<>( + client, null, null, "default", TestCustomResource.class, null, null, 200L, null); + + var first = pool.getInformer(CONTROLLER, ES_NAME, withLimit100); + var second = pool.getInformer("other-controller", "other-es", withLimit200); + + assertThat(first).isSameAs(second); + assertThat(pool.size()).isEqualTo(1); + verify(client, times(1)).resources(TestCustomResource.class); + } + + @Test + void doesNotStopSharedInformerUntilLastRelease() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier); + pool.getInformer("other-controller", "other-es", classifier); + + pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + verify(informer, never()).stop(); + assertThat(pool.size()).isEqualTo(1); + + pool.releaseInformer("other-controller", "other-es", classifier); + verify(informer, times(1)).stop(); + assertThat(pool.size()).isZero(); + } + + @Test + void releaseReturnsInformerEvenWhileStillShared() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier); + pool.getInformer("other-controller", "other-es", classifier); + + // the caller needs the (still-running) informer back so it can remove its own event handler + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + + assertThat(released).containsSame(informer); + verify(informer, never()).stop(); + } + + @Test + void releaseOfUnknownClassifierReturnsEmptyAndDoesNotThrow() { + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier("never-registered")); + + assertThat(released).isEmpty(); + assertThat(pool.size()).isZero(); + } + + private String masterUrl() { + return client.getConfiguration().getMasterUrl(); + } + + private InformerClassifier classifier(String namespace) { + return classifier(client, namespace); + } + + private InformerClassifier classifier( + KubernetesClient forClient, String namespace) { + return new InformerClassifier<>( + forClient, null, null, namespace, TestCustomResource.class, null, null, null, null); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifierTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifierTest.java new file mode 100644 index 0000000000..47f359a3f3 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerClassifierTest.java @@ -0,0 +1,315 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.processing.event.source.informer.pool; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.Config; +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; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResourceOtherV1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the identity semantics of {@link InformerClassifier}. The classifier is used as + * the key that decides whether two controllers share a single pooled informer, so its equality + * contract (in particular that {@code informerListLimit} is intentionally excluded and that the + * client is compared by identity) is load-bearing. + */ +class InformerClassifierTest { + + private static final KubernetesClient CLIENT = mock(KubernetesClient.class); + private static final String LABEL = "app=foo"; + private static final String SHARD = "shard-1"; + private static final String NAMESPACE = "default"; + private static final GroupVersionKind GVK = new GroupVersionKind("sample.io/v1", "Foo"); + private static final FieldSelector FIELD_SELECTOR = + new FieldSelector(new FieldSelector.Field("status.phase", "Running")); + private static final Long LIMIT = 100L; + private static final ItemStore ITEM_STORE = mock(ItemStore.class); + + private static InformerClassifier base() { + return new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE); + } + + @Test + void classifiersWithIdenticalFieldsAreEqual() { + assertThat(base()).isEqualTo(base()); + assertThat(base()).hasSameHashCodeAs(base()); + } + + @Test + void informerListLimitIsExcludedFromEqualityAndHashCode() { + var withOtherLimit = + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + 999L, + ITEM_STORE); + + assertThat(base()).isEqualTo(withOtherLimit); + assertThat(base()).hasSameHashCodeAs(withOtherLimit); + } + + @Test + void toStringContainsTheApiServerUrlDerivedFromTheClient() { + // the URL is not a component of its own, but classifiers are logged and the client alone does + // not tell which cluster it connects to + var config = mock(Config.class); + when(config.getMasterUrl()).thenReturn("https://localhost:8443/"); + var client = mock(KubernetesClient.class); + when(client.getConfiguration()).thenReturn(config); + + var classifier = + new InformerClassifier<>( + client, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE); + + assertThat(classifier.toString()) + .contains("https://localhost:8443/") + .contains(NAMESPACE) + .contains(LABEL) + .contains(TestCustomResource.class.getName()); + } + + @Test + void toStringDoesNotFailWithoutAClient() { + var classifier = + new InformerClassifier<>( + null, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE); + + assertThat(classifier.toString()).contains(NAMESPACE); + } + + @Test + void differsWhenClientDiffers() { + // two distinct clients may differ in credentials, impersonation or TLS material even when they + // report the same master URL, so they must never end up sharing an informer + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + mock(KubernetesClient.class), + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenLabelSelectorDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + CLIENT, + "app=bar", + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenShardSelectorDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + CLIENT, + LABEL, + "shard-2", + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenNamespaceDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + "other-ns", + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenResourceClassDiffers() { + // item stores are null here because their generic type is tied to the resource class, which is + // exactly the field under test; this keeps the resource class the only difference. + var forTestResource = + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + null); + var forOtherResource = + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResourceOtherV1.class, + GVK, + FIELD_SELECTOR, + LIMIT, + null); + + assertThat(forTestResource).isNotEqualTo(forOtherResource); + } + + @Test + void differsWhenGroupVersionKindDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + new GroupVersionKind("sample.io/v1", "Bar"), + FIELD_SELECTOR, + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenFieldSelectorDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + new FieldSelector(new FieldSelector.Field("status.phase", "Pending")), + LIMIT, + ITEM_STORE)); + } + + @Test + void differsWhenItemStoreDiffers() { + assertThat(base()) + .isNotEqualTo( + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + LIMIT, + mock(ItemStore.class))); + } + + @Test + void differsOnlyByInformerListLimitIsTrueWhenOnlyLimitDiffers() { + var withOtherLimit = + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + NAMESPACE, + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + 999L, + ITEM_STORE); + + assertThat(base().differsOnlyByInformerListLimit(withOtherLimit)).isTrue(); + } + + @Test + void differsOnlyByInformerListLimitIsFalseWhenFullyEqual() { + assertThat(base().differsOnlyByInformerListLimit(base())).isFalse(); + } + + @Test + void differsOnlyByInformerListLimitIsFalseWhenAnotherFieldDiffers() { + // different namespace AND different limit: not "only by limit" + var differentNamespaceAndLimit = + new InformerClassifier<>( + CLIENT, + LABEL, + SHARD, + "other-ns", + TestCustomResource.class, + GVK, + FIELD_SELECTOR, + 999L, + ITEM_STORE); + + assertThat(base().differsOnlyByInformerListLimit(differentNamespaceAndLimit)).isFalse(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPoolTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPoolTest.java new file mode 100644 index 0000000000..364103849e --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPoolTest.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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link NonSharingInformerPool}: unlike {@link DefaultInformerPool} it never shares + * informers. A distinct informer is created for every {@code controllerName}+{@code name}+{@code + * classifier} combination, and release is scoped to that same combination. + */ +class NonSharingInformerPoolTest { + + private static final String CONTROLLER = "controller"; + private static final String ES_NAME = "event-source"; + + private final KubernetesClient client = MockKubernetesClient.client(TestCustomResource.class); + private final NonSharingInformerPool pool = new NonSharingInformerPool(); + + @BeforeEach + void setUp() { + pool.setConfigurationService(new BaseConfigurationService()); + } + + @Test + void createsSeparateInformerForSameClassifierFromDifferentControllers() { + var classifier = classifier("default"); + + pool.getInformer(CONTROLLER, ES_NAME, classifier); + pool.getInformer("other-controller", ES_NAME, classifier); + + // no sharing: one informer created per user even for an identical classifier + assertThat(pool.size()).isEqualTo(2); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void createsSeparateInformerForDifferentEventSourceNames() { + var classifier = classifier("default"); + + pool.getInformer(CONTROLLER, "event-source-1", classifier); + pool.getInformer(CONTROLLER, "event-source-2", classifier); + + assertThat(pool.size()).isEqualTo(2); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void throwsWhenRequestingAnInformerForAnAlreadyRegisteredKey() { + var classifier = classifier("default"); + + pool.getInformer(CONTROLLER, ES_NAME, classifier); + + // requesting the same controller+event source+classifier combination again without releasing + // first would otherwise silently overwrite the map entry and leak the earlier informer + assertThatThrownBy(() -> pool.getInformer(CONTROLLER, ES_NAME, classifier)) + .isInstanceOf(OperatorException.class) + .hasMessageContaining(CONTROLLER) + .hasMessageContaining(ES_NAME) + .hasMessageContaining(classifier.toString()); + + // the earlier informer is left untouched, still registered exactly once + assertThat(pool.size()).isEqualTo(1); + verify(client, times(1)).resources(TestCustomResource.class); + } + + @Test + void allowsReRequestingAnInformerAfterItWasReleased() { + var classifier = classifier("default"); + pool.getInformer(CONTROLLER, ES_NAME, classifier); + pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + + // must not throw: releasing frees up the key for reuse + pool.getInformer(CONTROLLER, ES_NAME, classifier); + + assertThat(pool.size()).isEqualTo(1); + verify(client, times(2)).resources(TestCustomResource.class); + } + + @Test + void releaseStopsAndRemovesTheInformer() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier); + + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier); + + assertThat(released).containsSame(informer); + verify(informer, times(1)).stop(); + assertThat(pool.size()).isZero(); + } + + @Test + void releaseIsScopedToControllerAndName() { + var classifier = classifier("default"); + var informer = pool.getInformer(CONTROLLER, ES_NAME, classifier); + + // same classifier but a different controller: nothing is released or stopped + var released = pool.releaseInformer("other-controller", ES_NAME, classifier); + + assertThat(released).isEmpty(); + verify(informer, never()).stop(); + assertThat(pool.size()).isEqualTo(1); + } + + @Test + void releaseOfUnknownInformerReturnsEmptyAndDoesNotThrow() { + var released = pool.releaseInformer(CONTROLLER, ES_NAME, classifier("never-registered")); + + assertThat(released).isEmpty(); + assertThat(pool.size()).isZero(); + } + + private InformerClassifier classifier(String namespace) { + return new InformerClassifier<>( + client, null, null, namespace, TestCustomResource.class, null, null, null, null); + } +} diff --git a/operator-framework-junit/pom.xml b/operator-framework-junit/pom.xml index ef43d39195..aa18d5c778 100644 --- a/operator-framework-junit/pom.xml +++ b/operator-framework-junit/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.2-SNAPSHOT + 999-SNAPSHOT operator-framework-junit diff --git a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java index 2b2c3bec48..5e7602b094 100644 --- a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -51,6 +51,7 @@ import io.javaoperatorsdk.operator.RegisteredController; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.api.config.Utils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.processing.retry.Retry; @@ -558,11 +559,7 @@ public Builder withReconciler(Reconciler value, Retry retry) { @SuppressWarnings("rawtypes") public Builder withReconciler(Class value) { - try { - reconcilers.add(new ReconcilerSpec(value.getConstructor().newInstance(), null)); - } catch (Exception e) { - throw new RuntimeException(e); - } + reconcilers.add(new ReconcilerSpec(Utils.instantiate(value, Reconciler.class, null), null)); return this; } diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index 60bca8c8ff..6d314d4687 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.2-SNAPSHOT + 999-SNAPSHOT operator-framework diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index c8bee56793..103284c7cd 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -42,8 +42,7 @@ public List> prepareEventSourc new InformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, ChangeNamespaceTestCustomResource.class) - .build(), - context); + .build()); return List.of(configMapES); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceReconciler.java index ff1c1e4207..953d36f150 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceReconciler.java @@ -79,14 +79,13 @@ private ConfigMap desired(ClusterScopedCustomResource resource) { public List> prepareEventSources( EventSourceContext context) { var ies = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from( ConfigMap.class, ClusterScopedCustomResource.class) .withSecondaryToPrimaryMapper( Mappers.fromOwnerReferences(context.getPrimaryResourceClass(), true)) .withLabelSelector(TEST_LABEL_KEY + "=" + TEST_LABEL_VALUE) - .build(), - context); + .build()); return List.of(ies); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java index 4344356ff9..23056be0fe 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java @@ -102,7 +102,9 @@ public List> prepareEv .withComparableResourceVersion(comparableResourceVersion) .build(); - final var informerEventSource = new InformerEventSource<>(informerConfiguration, context); + final var informerEventSource = + new InformerEventSource( + informerConfiguration); this.configMapDR.setEventSource(informerEventSource); return List.of(informerEventSource); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationReconciler.java index e9e5105587..88267b47ba 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationReconciler.java @@ -45,10 +45,8 @@ public UpdateControl reconc context .eventSourceRetriever() - .dynamicallyRegisterEventSource(genericInformerFor(ConfigMap.class, context)); - context - .eventSourceRetriever() - .dynamicallyRegisterEventSource(genericInformerFor(Secret.class, context)); + .dynamicallyRegisterEventSource(genericInformerFor(ConfigMap.class)); + context.eventSourceRetriever().dynamicallyRegisterEventSource(genericInformerFor(Secret.class)); context.getClient().resource(secret(primary)).createOr(NonDeletingOperation::update); context.getClient().resource(configMap(primary)).createOr(NonDeletingOperation::update); @@ -89,17 +87,14 @@ private ConfigMap configMap(DynamicGenericEventSourceRegistrationCustomResource private InformerEventSource< GenericKubernetesResource, DynamicGenericEventSourceRegistrationCustomResource> - genericInformerFor( - Class clazz, - Context context) { + genericInformerFor(Class clazz) { return new InformerEventSource<>( InformerEventSourceConfiguration.from( GroupVersionKind.gvkFor(clazz), DynamicGenericEventSourceRegistrationCustomResource.class) .withName(clazz.getSimpleName()) - .build(), - context.eventSourceRetriever().eventSourceContextForDynamicRegistration()); + .build()); } public int getNumberOfExecutions() { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/onallevent/ExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/onallevent/ExpectationReconciler.java index 5c0b4dcebe..460cbbee98 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/onallevent/ExpectationReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/onallevent/ExpectationReconciler.java @@ -103,8 +103,7 @@ public List> prepareEventSources( return List.of( new InformerEventSource<>( InformerEventSourceConfiguration.from(Deployment.class, ExpectationCustomResource.class) - .build(), - context)); + .build())); } private static void createDeployment( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/periodicclean/PeriodicCleanerExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/periodicclean/PeriodicCleanerExpectationReconciler.java index 7fc6f5bf82..f903df479d 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/periodicclean/PeriodicCleanerExpectationReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/periodicclean/PeriodicCleanerExpectationReconciler.java @@ -128,8 +128,7 @@ public List> prepareEve new InformerEventSource<>( InformerEventSourceConfiguration.from( Deployment.class, PeriodicCleanerExpectationCustomResource.class) - .build(), - context)); + .build())); } private static void createDeployment( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusCustomResource.java new file mode 100644 index 0000000000..89c0c993e4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusCustomResource.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.externalstateinstatus; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("essis") +public class ExternalStateInStatusCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusIT.java new file mode 100644 index 0000000000..52da0925ef --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusIT.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.baseapi.externalstateinstatus; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalServiceResetExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Managing an External Resource with State Stored in the Status", + description = + """ + Demonstrates how to manage an external resource (outside of Kubernetes) while storing its \ + state - the generated external ID - in the status of the custom resource. The reconciler \ + persists the ID with a status patch and relies on the stronger read-after-write \ + consistency for updates so that the next reconciliation observes the stored ID and never \ + creates a duplicate external resource. A fake external service stands in for the managed \ + external system. + """) +@ExtendWith(ExternalServiceResetExtension.class) +class ExternalStateInStatusIT { + + private static final String TEST_RESOURCE_NAME = "test1"; + + public static final String INITIAL_TEST_DATA = "initialTestData"; + public static final String UPDATED_DATA = "updatedData"; + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(ExternalStateInStatusReconciler.class) + .build(); + + @Test + void reconcilesResourceWithStateStoredInStatus() { + var resource = operator.create(testResource()); + assertResourceCreated(INITIAL_TEST_DATA); + + resource.getSpec().setData(UPDATED_DATA); + operator.replace(resource); + assertResourceCreated(UPDATED_DATA); + + operator.delete(resource); + assertResourceDeleted(); + } + + private void assertResourceCreated(String expectedData) { + await() + .untilAsserted( + () -> { + var resources = externalService.listResources(); + // exactly one external resource is created, no duplicates + assertThat(resources).hasSize(1); + var extRes = resources.get(0); + assertThat(extRes.getData()).isEqualTo(expectedData); + + var cr = operator.get(ExternalStateInStatusCustomResource.class, TEST_RESOURCE_NAME); + assertThat(cr.getStatus()).isNotNull(); + // the external resource state (its ID) is stored in the status + assertThat(cr.getStatus().getId()).isEqualTo(extRes.getId()); + }); + } + + private void assertResourceDeleted() { + await().untilAsserted(() -> assertThat(externalService.listResources()).isEmpty()); + } + + private ExternalStateInStatusCustomResource testResource() { + var res = new ExternalStateInStatusCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new ExternalStateInStatusSpec().setData(INITIAL_TEST_DATA)); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusReconciler.java new file mode 100644 index 0000000000..3924631e1a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusReconciler.java @@ -0,0 +1,153 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.externalstateinstatus; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingConfigurationBuilder; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalResource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * Manages an external resource (living in the {@link ExternalIDGenServiceMock fake external + * service}) while storing the external resource's state - here just its generated ID - directly in + * the status of the custom resource. + * + *

This pattern only works reliably thanks to the stronger read-after-write consistency for + * updates: after the external resource is created, its ID is persisted through {@link + * UpdateControl#patchStatus(io.fabric8.kubernetes.api.model.HasMetadata)}. The patched resource + * (holding the ID) is placed into the controller's cache, so the very next reconciliation observes + * the ID and does not create a duplicate external resource - even before the informer delivers the + * update event. + */ +@ControllerConfiguration +public class ExternalStateInStatusReconciler + implements Reconciler, + Cleaner, + TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + PerResourcePollingEventSource + externalResourceEventSource; + + @Override + public UpdateControl reconcile( + ExternalStateInStatusCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + var externalResource = context.getSecondaryResource(ExternalResource.class); + if (externalResource.isEmpty()) { + var id = idFromStatus(resource); + if (id == null || externalService.read(id).isEmpty()) { + return createExternalResource(resource); + } + return UpdateControl.noUpdate(); + } + + var currentExternalResource = externalResource.orElseThrow(); + if (!currentExternalResource.getData().equals(resource.getSpec().getData())) { + updateExternalResource(resource, currentExternalResource); + } + return UpdateControl.noUpdate(); + } + + private UpdateControl createExternalResource( + ExternalStateInStatusCustomResource resource) { + var createdResource = + externalService.create(new ExternalResource(resource.getSpec().getData())); + + // Make sure the freshly created external resource is available in the poll cache for the next + // reconciliation, so it is not created again. + externalResourceEventSource.handleRecentResourceCreate( + ResourceID.fromResource(resource), createdResource); + + resource.setStatus(new ExternalStateInStatusStatus().setId(createdResource.getId())); + return UpdateControl.patchStatus(resource); + } + + private void updateExternalResource( + ExternalStateInStatusCustomResource resource, ExternalResource externalResource) { + var newResource = new ExternalResource(externalResource.getId(), resource.getSpec().getData()); + externalService.update(newResource); + externalResourceEventSource.handleRecentResourceUpdate( + ResourceID.fromResource(resource), newResource, externalResource); + } + + @Override + public DeleteControl cleanup( + ExternalStateInStatusCustomResource resource, + Context context) { + var id = idFromStatus(resource); + if (id != null) { + externalService.delete(id); + } + return DeleteControl.defaultDelete(); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + final PerResourcePollingEventSource.ResourceFetcher< + ExternalResource, ExternalStateInStatusCustomResource> + fetcher = + (ExternalStateInStatusCustomResource primaryResource) -> { + var id = idFromStatus(primaryResource); + if (id == null) { + return Collections.emptySet(); + } + return externalService.read(id).map(Set::of).orElseGet(Collections::emptySet); + }; + externalResourceEventSource = + new PerResourcePollingEventSource<>( + ExternalResource.class, + context, + new PerResourcePollingConfigurationBuilder< + ExternalResource, ExternalStateInStatusCustomResource, String>( + fetcher, Duration.ofMillis(300L)) + .build()); + + return List.of(externalResourceEventSource); + } + + private static String idFromStatus(ExternalStateInStatusCustomResource resource) { + return resource.getStatus() == null ? null : resource.getStatus().getId(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusSpec.java new file mode 100644 index 0000000000..1eecb6959c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusSpec.java @@ -0,0 +1,30 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.externalstateinstatus; + +public class ExternalStateInStatusSpec { + + private String data; + + public String getData() { + return data; + } + + public ExternalStateInStatusSpec setData(String data) { + this.data = data; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusStatus.java new file mode 100644 index 0000000000..f947448760 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/externalstateinstatus/ExternalStateInStatusStatus.java @@ -0,0 +1,31 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.externalstateinstatus; + +/** Holds the identifier of the managed external resource. This is the external resource state. */ +public class ExternalStateInStatusStatus { + + private String id; + + public String getId() { + return id; + } + + public ExternalStateInStatusStatus setId(String id) { + this.id = id; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java index 49a4e39b38..50e16103a8 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java @@ -72,8 +72,7 @@ public List> prepareEventSources(EventSourceContext> prepareEventSources( !newCM.getData().get(CM_VALUE_KEY).equals(CONFIG_MAP_FILTER_VALUE)) .build(); InformerEventSource configMapES = - new InformerEventSource<>(informerConfiguration, context); + new InformerEventSource<>(informerConfiguration); return List.of(configMapES); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java index 7efa8a0ad6..bebca87195 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java @@ -66,12 +66,12 @@ public List> pre EventSourceContext context) { var informerEventSource = - new InformerEventSource<>( + new InformerEventSource< + GenericKubernetesResource, GenericKubernetesResourceHandlingCustomResource>( InformerEventSourceConfiguration.from( new GroupVersionKind("", VERSION, KIND), GenericKubernetesResourceHandlingCustomResource.class) - .build(), - context); + .build()); return List.of(informerEventSource); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomReconciler.java index 2ca81d99d5..0b9e19c4fd 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomReconciler.java @@ -61,7 +61,7 @@ public List> prepareEventS InformerEventSourceTestCustomResource.class)) .build(); - return List.of(new InformerEventSource<>(config, context)); + return List.of(new InformerEventSource<>(config)); } @Override diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AbstractSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AbstractSharedInformerIT.java new file mode 100644 index 0000000000..e6da24b467 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/basic/AbstractSharedInformerIT.java @@ -0,0 +1,107 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.basic; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.AbstractInformerPool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Registers two controllers, each backed by its own primary custom resource, that both watch {@link + * ConfigMap} as a secondary resource using an identical {@code InformerEventSource} configuration. + * + *

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

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

+ * + *

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

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

The event source is registered while the shared informer is already running and has the + * pre-existing third resource in its cache. Because the handler is added to an already-running + * informer, the framework replays the resources already in that informer's cache to this newly + * added handler on registration, so this reconciler is triggered for the pre-existing third + * resource. This is asserted by the integration test. + */ +@ControllerConfiguration +public class DynamicSharedInformerReconciler + implements Reconciler { + + public static final String PRIMARY_NAME = "dynamic-primary"; + public static final String THIRD_EVENT_SOURCE_NAME = "dynamic-third-informer"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DynamicSharedInformerPrimaryCustomResource2 resource, + Context context) { + numberOfExecutions.incrementAndGet(); + context.eventSourceRetriever().dynamicallyRegisterEventSource(thirdResourceEventSource()); + return UpdateControl.noUpdate(); + } + + private InformerEventSource< + DynamicSharedInformerThirdCustomResource, DynamicSharedInformerPrimaryCustomResource2> + thirdResourceEventSource() { + var config = + InformerEventSourceConfiguration.from( + DynamicSharedInformerThirdCustomResource.class, + DynamicSharedInformerPrimaryCustomResource2.class) + .withName(THIRD_EVENT_SOURCE_NAME) + .withSecondaryToPrimaryMapper( + (DynamicSharedInformerThirdCustomResource third) -> + Set.of(new ResourceID(PRIMARY_NAME, third.getMetadata().getNamespace()))) + .build(); + return new InformerEventSource<>(config); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerThirdCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerThirdCustomResource.java new file mode 100644 index 0000000000..fb4ad62586 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/DynamicSharedInformerThirdCustomResource.java @@ -0,0 +1,34 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * The "third" custom resource that is watched as a secondary resource by two different reconcilers. + * It has no reconciler of its own. One reconciler watches it via a statically registered event + * source, the other via a dynamically registered one; both are expected to share a single informer + * for this type from the informer pool. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("dsi3") +public class DynamicSharedInformerThirdCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/NonSharingInformerPoolDynamicSharedInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/NonSharingInformerPoolDynamicSharedInformerIT.java new file mode 100644 index 0000000000..f06d8155e2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/NonSharingInformerPoolDynamicSharedInformerIT.java @@ -0,0 +1,41 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import java.util.function.Consumer; + +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.NonSharingInformerPool; + +/** + * Runs {@link AbstractDynamicSharedInformerIT} with the {@link NonSharingInformerPool}: the static + * and dynamic reconcilers each get their own informer for the third resource. The dynamic + * reconciler is still triggered for the pre-existing third resource, because its own (newly + * created) informer replays the cache to the handler once it syncs. + */ +class NonSharingInformerPoolDynamicSharedInformerIT extends AbstractDynamicSharedInformerIT { + + @Override + protected Consumer configurationServiceOverrider() { + return overrider -> overrider.withInformerPool(new NonSharingInformerPool()); + } + + @Override + protected long expectedThirdResourceInformerCount() { + // no sharing: one informer for the static reconciler and one for the dynamic reconciler + return 2; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/StaticSharedInformerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/StaticSharedInformerReconciler.java new file mode 100644 index 0000000000..75db3ab3e8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerpool/dynamic/StaticSharedInformerReconciler.java @@ -0,0 +1,73 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.informerpool.dynamic; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Watches {@link DynamicSharedInformerThirdCustomResource} (the "third" custom resource) as a + * secondary resource using a statically registered informer event source. Because this + * event source is registered at startup, its handler is present on the underlying informer before + * any third resource exists, so this reconciler is triggered by third-resource events. It is the + * counterpart to {@link DynamicSharedInformerReconciler}, which watches the same third resource but + * registers its event source dynamically. + */ +@ControllerConfiguration +public class StaticSharedInformerReconciler + implements Reconciler { + + public static final String PRIMARY_NAME = "static-primary"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var config = + InformerEventSourceConfiguration.from( + DynamicSharedInformerThirdCustomResource.class, + DynamicSharedInformerPrimaryCustomResource1.class) + .withSecondaryToPrimaryMapper( + (DynamicSharedInformerThirdCustomResource third) -> + Set.of(new ResourceID(PRIMARY_NAME, third.getMetadata().getNamespace()))) + .build(); + return List.of(new InformerEventSource<>(config)); + } + + @Override + public UpdateControl reconcile( + DynamicSharedInformerPrimaryCustomResource1 resource, + Context context) { + numberOfExecutions.incrementAndGet(); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterReconciler.java index 9dba692721..e5dfed4534 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterReconciler.java @@ -70,7 +70,7 @@ public List> prepareEventSou EventSourceContext context) { var es = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from( ConfigMap.class, InformerRemoteClusterCustomResource.class) // owner references do not work cross cluster, using @@ -80,8 +80,7 @@ public List> prepareEventSou // setting remote client for informer .withKubernetesClient(remoteClient) .withWatchAllNamespaces() - .build(), - context); + .build()); return List.of(es); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java index 92eb5aa8fa..c7b7f18b90 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java @@ -126,9 +126,7 @@ public List> prepareEventSources( cm.getMetadata().getNamespace()))) .build(); - return List.of( - new InformerEventSource<>(configEs1, context), - new InformerEventSource<>(configEs2, context)); + return List.of(new InformerEventSource<>(configEs1), new InformerEventSource<>(configEs2)); } @Override diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java index 2a11be1faf..fb151fd347 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java @@ -87,7 +87,7 @@ public List> prepareE }) .build(); InformerEventSource - configMapEventSource = new InformerEventSource<>(config, context); + configMapEventSource = new InformerEventSource<>(config); return List.of(configMapEventSource); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDDeserializationRetryIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDDeserializationRetryIT.java new file mode 100644 index 0000000000..266a93781b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDDeserializationRetryIT.java @@ -0,0 +1,158 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.client.informers.ExceptionHandler; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.api.config.InformerStoppedHandler; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Companion of {@link MultiVersionCRDIT}: there the resource that cannot be deserialized shows up + * while the informer is already watching, here it is already present when the informer lists the + * resources on startup. Since {@code stopOnInformerErrorDuringStartup} is {@code false} the + * operator starts anyway, but the informer is stopped for good and not retried after a + * deserialization failure; this test documents what happens when the problem is fixed in the + * cluster while the operator is running. + */ +@Sample( + tldr = "Informer Retry After a Custom Resource Deserialization Problem", + description = + """ + Shows what happens to an operator whose informer cannot deserialize an already existing \ + custom resource, the situation described in the "Multi Version Custom Resources \ + Deserialization Problem" ADR: a resource created as v2 is stored as v1 because there is \ + no conversion hook, so the reconciler watching v1 receives a String where its spec \ + declares an int. With stopOnInformerErrorDuringStartup set to false the operator still \ + starts, but the informer of the affected controller is stopped for good: the test \ + documents that removing the offending resource while the operator is running does not \ + bring the informer back, the operator has to be restarted. + """) +class MultiVersionCRDDeserializationRetryIT { + + private static final Logger log = + LoggerFactory.getLogger(MultiVersionCRDDeserializationRetryIT.class); + + public static final String NOT_DESERIALIZABLE_CR_NAME = "not-deserializable"; + public static final String VALID_CR_NAME = "valid"; + + private final CapturingInformerStoppedHandler informerStoppedHandler = + new CapturingInformerStoppedHandler(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + // only the reconciler for v1 is registered, it watches the resources without a "version" + // label, thus also the one created below as v2 + .withReconciler(new MultiVersionCRDTestReconciler1()) + .withConfigurationService( + overrider -> + overrider + .withStopOnInformerErrorDuringStartup(false) + .withInformerStoppedHandler(informerStoppedHandler)) + // v1 is the stored version and there is no conversion hook, so this resource is stored as + // it was sent: with a String in the field that v1 declares as an int. The informer of the + // v1 controller therefore already fails to deserialize it while listing on startup. + .withBeforeStartHook(extension -> extension.create(notDeserializableResource())) + .build(); + + @Test + void informerIsNotRetriedAfterTheProblemIsFixedInTheCluster() { + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted(() -> assertThat(informerStoppedHandler.getError()).isNotNull()); + assertThat(ExceptionHandler.isDeserializationException(informerStoppedHandler.getError())) + .isTrue(); + assertThat(operator.getOperator().getRuntimeInfo().allEventSourcesAreHealthy()).isFalse(); + + // the problem is fixed while the operator is running: the resource that cannot be deserialized + // is removed. It is deleted through the v2 endpoint, where it can be deserialized. + operator.delete(notDeserializableResource()); + await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofMillis(50)) + .untilAsserted( + () -> + assertThat( + operator.get( + MultiVersionCRDTestCustomResource2.class, NOT_DESERIALIZABLE_CR_NAME)) + .isNull()); + operator.create(validResource()); + + // Nothing is reconciled: a deserialization error is excluded from the informer retries (see + // AbstractInformerPool#createInformer) and the Reflector of the fabric8 client completes its + // stop future as soon as its exception handler declines a retry, so the operator never notices + // that the cluster is in order again. If this assertion starts to fail because the resource got + // reconciled, the informer is retried after all: turn the assertions around, that is the + // behavior we want. + await() + .pollDelay(Duration.ofSeconds(5)) + .atMost(Duration.ofSeconds(20)) + .untilAsserted( + () -> { + var actual = operator.get(MultiVersionCRDTestCustomResource1.class, VALID_CR_NAME); + assertThat(actual).isNotNull(); + assertThat(actual.getStatus()).isNull(); + }); + assertThat(operator.getOperator().getRuntimeInfo().allEventSourcesAreHealthy()).isFalse(); + } + + static MultiVersionCRDTestCustomResource2 notDeserializableResource() { + var cr = new MultiVersionCRDTestCustomResource2(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(NOT_DESERIALIZABLE_CR_NAME); + cr.setSpec(new MultiVersionCRDTestCustomResourceSpec2()); + cr.getSpec().setValue("string value"); + return cr; + } + + static MultiVersionCRDTestCustomResource1 validResource() { + var cr = new MultiVersionCRDTestCustomResource1(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(VALID_CR_NAME); + cr.setSpec(new MultiVersionCRDTestCustomResourceSpec1()); + cr.getSpec().setValue(1); + return cr; + } + + private static class CapturingInformerStoppedHandler implements InformerStoppedHandler { + + private volatile Throwable error; + + @Override + @SuppressWarnings("rawtypes") + public void onStop(SharedIndexInformer informer, Throwable ex) { + log.info("Informer for {} stopped", informer.getApiTypeClass().getName(), ex); + error = ex; + } + + Throwable getError() { + return error; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ownerreferencemultiversion/OwnerRefMultiVersionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ownerreferencemultiversion/OwnerRefMultiVersionReconciler.java index c9680b0c81..b0a4220d00 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ownerreferencemultiversion/OwnerRefMultiVersionReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ownerreferencemultiversion/OwnerRefMultiVersionReconciler.java @@ -77,13 +77,12 @@ public UpdateControl reconcile( public List> prepareEventSources( EventSourceContext context) { var ies = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from(ConfigMap.class, OwnerRefMultiVersionCR1.class) .withSecondaryToPrimaryMapper( Mappers.fromOwnerReferences(context.getPrimaryResourceClass())) .withLabelSelector(LABEL_KEY + "=" + LABEL_VALUE) - .build(), - context); + .build()); return List.of(ies); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestReconciler.java index 8b3a5e044f..d011b86268 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestReconciler.java @@ -49,6 +49,6 @@ public List> prepareEventSource .collect(Collectors.toSet())) .build(); - return List.of(new InformerEventSource<>(informerConfiguration, context)); + return List.of(new InformerEventSource<>(informerConfiguration)); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobReconciler.java index a2a1b89ed1..ef2c26e19f 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobReconciler.java @@ -123,7 +123,7 @@ public List> prepareEventSources(EventSourceContext con primary.getMetadata().getNamespace()))); } - return List.of(new InformerEventSource<>(informerConfiguration.build(), context)); + return List.of(new InformerEventSource<>(informerConfiguration.build())); } private String indexKey(String clusterName, String namespace) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java index 0dac8cae33..bf5a6c3303 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java @@ -102,8 +102,7 @@ public List> prepareEventS new InformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, ExternalSecondaryUpdateCustomResource.class) - .build(), - context); + .build()); return List.of(configMapEventSource); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index 5f3ead43ff..80c2d92c26 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -141,8 +141,7 @@ public List> prepareEventSources( new RelistAwareInformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, OnRelistFilterCustomResource.class) - .build(), - context); + .build()); return List.of(configMapEventSource); } @@ -180,9 +179,8 @@ static class RelistAwareInformerEventSource latestReceivedVersion = new ConcurrentHashMap<>(); - RelistAwareInformerEventSource( - InformerEventSourceConfiguration configuration, EventSourceContext

context) { - super(configuration, context); + RelistAwareInformerEventSource(InformerEventSourceConfiguration configuration) { + super(configuration); } @Override diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java index 8a95f6fed8..dee561ff10 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java @@ -68,8 +68,7 @@ public List> prepareEventSource new InformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, OwnSecondaryUpdateCustomResource.class) - .build(), - context); + .build()); return List.of(configMapEventSource); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/readownupdates/ReadOwnUpdatesReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/readownupdates/ReadOwnUpdatesReconciler.java index 545916d7f2..c62d637a88 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/readownupdates/ReadOwnUpdatesReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/readownupdates/ReadOwnUpdatesReconciler.java @@ -123,8 +123,7 @@ public List> prepareEventSources( new InformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, ReadOwnUpdatesCustomResource.class) - .build(), - context); + .build()); configMapEventSource.addIndexers( Map.of(RESOURCE_VERSION_INDEX, cm -> List.of(cm.getMetadata().getResourceVersion()))); return List.of(configMapEventSource); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java index 968046da27..16d7142899 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java @@ -129,8 +129,7 @@ public List> prepareEv new InformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, SecondaryResourceOperationsCustomResource.class) - .build(), - context); + .build()); return List.of(configMapEventSource); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java index ee8d11e9d4..8e85241a51 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -55,7 +55,7 @@ public List> prepareEventSources( .withSecondaryToPrimaryMapper(new ConfigToTargetMapper()) .build(); - var ies = new InformerEventSource<>(configuration, context); + var ies = new InformerEventSource(configuration); return List.of(ies); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java index 974427ba43..7ce8c21dfb 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java @@ -133,8 +133,7 @@ public List> prepareEventSources( InformerEventSource es = new InformerEventSource<>( InformerEventSourceConfiguration.from(ConfigMap.class, TestCustomResource.class) - .build(), - context); + .build()); return List.of(es); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessReconciler.java index ff2eb33bd5..c01505d0b8 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessReconciler.java @@ -75,8 +75,7 @@ public List> prepareEventSo InformerEventSourceConfiguration.from( ConfigMap.class, StartupSecondaryAccessCustomResource.class) .withLabelSelector(LABEL_KEY + "=" + LABEL_VALUE) - .build(), - context); + .build()); return List.of(cmInformer); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java index 744f01bbec..0f193d9440 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClientException; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; import io.javaoperatorsdk.operator.processing.retry.GenericRetry; @@ -218,13 +219,6 @@ void additionalEventDuringRetryOnDeleteEvent() { addNoMoreExceptionAnnotation(); - await() - .untilAsserted( - () -> { - var r = getResource(); - assertThat(r.getMetadata().getFinalizers()).doesNotContain(FINALIZER); - }); - removeAdditionalFinalizerWaitForResourceDeletion(); } @@ -258,9 +252,24 @@ void additionalEventAfterExhaustedRetry() { } private void removeAdditionalFinalizerWaitForResourceDeletion() { - var res = getResource(); - res.removeFinalizer(ADDITIONAL_FINALIZER); - extension.update(res); + // The reconciler removes its own finalizer during the reconciliation triggered above, but the + // event count is already increased at the beginning of the reconciliation. Therefore, waiting + // for the event count alone would release the test into the middle of that reconciliation, and + // the update below would race with the finalizer removal. + await() + .untilAsserted( + () -> + assertThat(getResource().getMetadata().getFinalizers()) + .containsExactly(ADDITIONAL_FINALIZER)); + // update is optimistically locked, so retry with a fresh read on conflict + await() + .ignoreException(KubernetesClientException.class) + .untilAsserted( + () -> { + var res = getResource(); + res.removeFinalizer(ADDITIONAL_FINALIZER); + extension.update(res); + }); await().untilAsserted(() -> assertThat(getResource()).isNull()); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/ConfigMapDependentResource.java new file mode 100644 index 0000000000..5a77e37cb0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/ConfigMapDependentResource.java @@ -0,0 +1,58 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.dependent.detectapiversionchange; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent(detectApiVersionChange = true) +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + public static final String KEY = "key"; + + public static final AtomicInteger updateCount = new AtomicInteger(0); + + @Override + protected ConfigMap desired( + DetectApiVersionChangeCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(KEY, "value")); + return configMap; + } + + @Override + public ConfigMap update( + ConfigMap actual, + ConfigMap desired, + DetectApiVersionChangeCustomResource primary, + Context context) { + updateCount.incrementAndGet(); + return super.update(actual, desired, primary, context); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeCustomResource.java new file mode 100644 index 0000000000..9cb52ad8bd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeCustomResource.java @@ -0,0 +1,26 @@ +/* + * 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.dependent.detectapiversionchange; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class DetectApiVersionChangeCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeIT.java new file mode 100644 index 0000000000..73e772c00e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeIT.java @@ -0,0 +1,103 @@ +/* + * 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.dependent.detectapiversionchange; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Detects API version changes of a dependent resource", + description = + """ + Shows how a dependent resource configured with `detectApiVersionChange` records the API \ + version it applies in a marker annotation, and how a stale marker (for example left \ + behind by an older CRD/operator version) triggers exactly one update to bring the marker \ + back up to date, without causing further reconciliation loops. + """) +class DetectApiVersionChangeIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String STALE_API_VERSION = "stale.example.com/v1alpha1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new DetectApiVersionChangeReconciler()) + .build(); + + @Test + void marksAndFixesStaleApiVersionMarker() { + operator.create(testResource()); + + // the marker annotation is set to the current API version on initial creation + await() + .untilAsserted( + () -> { + var configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(configMap).isNotNull(); + assertThat(configMap.getMetadata().getAnnotations()) + .containsEntry( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, "v1"); + }); + + // creation does not count as an update + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted(() -> assertThat(ConfigMapDependentResource.updateCount.get()).isZero()); + + // simulate a marker left behind by an older operator/CRD version + var configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + configMap + .getMetadata() + .getAnnotations() + .put( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, STALE_API_VERSION); + operator.update(configMap); + + // the stale marker is detected and fixed with a single update + await() + .untilAsserted( + () -> { + var updated = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(updated.getMetadata().getAnnotations()) + .containsEntry( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, "v1"); + assertThat(ConfigMapDependentResource.updateCount.get()).isEqualTo(1); + }); + + // no further updates are triggered once the marker is up-to-date again + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted(() -> assertThat(ConfigMapDependentResource.updateCount.get()).isEqualTo(1)); + } + + DetectApiVersionChangeCustomResource testResource() { + var res = new DetectApiVersionChangeCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeReconciler.java new file mode 100644 index 0000000000..9f9dd89ee4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/detectapiversionchange/DetectApiVersionChangeReconciler.java @@ -0,0 +1,41 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.dependent.detectapiversionchange; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = ConfigMapDependentResource.class)}) +@ControllerConfiguration +public class DetectApiVersionChangeReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DetectApiVersionChangeCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentReconciler.java index f8f2e23477..7e8ad5f8ff 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentReconciler.java @@ -50,11 +50,10 @@ public int getNumberOfExecutions() { public List> prepareEventSources( EventSourceContext context) { var configMapEventSource = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from( ConfigMap.class, ExternalStateCustomResource.class) - .build(), - context); + .build()); return List.of(configMapEventSource); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java index 4f4cab80d7..51b3d24054 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java @@ -141,8 +141,7 @@ public List> prepareEventSources( new InformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, ExternalStateCustomResource.class) - .build(), - context); + .build()); configMapEventSource.setEventSourcePriority(EventSourceStartPriority.RESOURCE_STATE_LOADER); final PerResourcePollingEventSource.ResourceFetcher< diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java index ba08f7fdfa..eb699f225e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java @@ -135,12 +135,21 @@ public Map desiredResources( return res; } + /** + * Resolves the actual resources from the persisted state instead of the polled cache. An external + * resource and the state referencing it cannot be created atomically, so a poll happening in + * between replaces the cached resources with the ones it can already see, dropping the freshly + * created one. The next reconciliation would then create a duplicate external resource that no + * state references anymore, thus is leaked. The state itself is read-after-write consistent, + * since it is managed through an {@link + * io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}. + */ @Override public Map getSecondaryResources( ExternalStateBulkDependentCustomResource primary, Context context) { - var resources = context.getSecondaryResources(ExternalResource.class); - return resources.stream().collect(Collectors.toMap(this::externalResourceIndex, r -> r)); + return fetchResources(primary).stream() + .collect(Collectors.toMap(this::externalResourceIndex, r -> r)); } @Override diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentReconciler.java index 365ac6bb7b..300c94e703 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentReconciler.java @@ -50,11 +50,10 @@ public int getNumberOfExecutions() { public List> prepareEventSources( EventSourceContext context) { var configMapEventSource = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from( ConfigMap.class, ExternalStateBulkDependentCustomResource.class) - .build(), - context); + .build()); return List.of(configMapEventSource); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusDependentResource.java new file mode 100644 index 0000000000..974214a5e3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusDependentResource.java @@ -0,0 +1,117 @@ +/* + * 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.dependent.externalstateinstatus; + +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.external.PerResourcePollingDependentResource; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalResource; + +/** + * Dependent resource managing an external resource in the {@link ExternalIDGenServiceMock fake + * external service}. The external resource's state - its generated ID - is read from the status + * of the primary custom resource. The ID is written into the status by {@link + * ExternalStateInStatusWorkflowReconciler} after the workflow reconciled this dependent, relying on + * the stronger read-after-write consistency for updates so that the next fetch/reconciliation + * observes the ID and does not create a duplicate external resource. + */ +public class ExternalStateInStatusDependentResource + extends PerResourcePollingDependentResource< + ExternalResource, ExternalStateInStatusWorkflowCustomResource, String> + implements Creator, + Updater, + Deleter { + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + public ExternalStateInStatusDependentResource() { + super(ExternalResource.class, Duration.ofMillis(300)); + } + + @Override + public Set fetchResources( + ExternalStateInStatusWorkflowCustomResource primaryResource) { + return idFromStatus(primaryResource) + .flatMap(externalService::read) + .map(Set::of) + .orElseGet(Collections::emptySet); + } + + @Override + protected Optional selectTargetSecondaryResource( + Set secondaryResources, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return idFromStatus(primary) + .flatMap(id -> secondaryResources.stream().filter(e -> e.getId().equals(id)).findAny()); + } + + @Override + protected ExternalResource desired( + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return new ExternalResource(primary.getSpec().getData()); + } + + @Override + public ExternalResource create( + ExternalResource desired, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return externalService.create(desired); + } + + @Override + public ExternalResource update( + ExternalResource actual, + ExternalResource desired, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return externalService.update(new ExternalResource(actual.getId(), desired.getData())); + } + + @Override + public Matcher.Result match( + ExternalResource resource, + ExternalStateInStatusWorkflowCustomResource primary, + Context context) { + return Matcher.Result.nonComputed(resource.getData().equals(primary.getSpec().getData())); + } + + @Override + protected void handleDelete( + ExternalStateInStatusWorkflowCustomResource primary, + ExternalResource secondary, + Context context) { + if (secondary != null) { + externalService.delete(secondary.getId()); + } + } + + private static Optional idFromStatus( + ExternalStateInStatusWorkflowCustomResource primary) { + return Optional.ofNullable(primary.getStatus()).map(ExternalStateInStatusStatus::getId); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusSpec.java new file mode 100644 index 0000000000..7bd6cabc0f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusSpec.java @@ -0,0 +1,30 @@ +/* + * 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.dependent.externalstateinstatus; + +public class ExternalStateInStatusSpec { + + private String data; + + public String getData() { + return data; + } + + public ExternalStateInStatusSpec setData(String data) { + this.data = data; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusStatus.java new file mode 100644 index 0000000000..8d331b5ad3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusStatus.java @@ -0,0 +1,31 @@ +/* + * 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.dependent.externalstateinstatus; + +/** Holds the identifier of the managed external resource. This is the external resource state. */ +public class ExternalStateInStatusStatus { + + private String id; + + public String getId() { + return id; + } + + public ExternalStateInStatusStatus setId(String id) { + this.id = id; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowCustomResource.java new file mode 100644 index 0000000000..252616a693 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowCustomResource.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.dependent.externalstateinstatus; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("essisw") +public class ExternalStateInStatusWorkflowCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowIT.java new file mode 100644 index 0000000000..7489ee3a69 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowIT.java @@ -0,0 +1,107 @@ +/* + * 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.dependent.externalstateinstatus; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalServiceResetExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Manages an external resource through a managed workflow with a {@link + * ExternalStateInStatusDependentResource dependent resource}, while storing the external resource + * state (its ID) in the status of the custom resource. This only works reliably because of the + * stronger read-after-write consistency for updates. + */ +@Sample( + tldr = "Managing an External Resource via a Workflow with State Stored in the Status", + description = + """ + Demonstrates managing an external resource (outside of Kubernetes) with a managed workflow \ + and a dependent resource, while storing its state - the generated external ID - in the \ + status of the custom resource. The reconciler persists the ID with a status patch and \ + relies on the stronger read-after-write consistency for updates so that the next \ + reconciliation and the dependent's fetch observe the stored ID and never create a \ + duplicate external resource. A fake external service stands in for the managed external \ + system. + """) +@ExtendWith(ExternalServiceResetExtension.class) +class ExternalStateInStatusWorkflowIT { + + private static final String TEST_RESOURCE_NAME = "test1"; + + public static final String INITIAL_TEST_DATA = "initialTestData"; + public static final String UPDATED_DATA = "updatedData"; + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(ExternalStateInStatusWorkflowReconciler.class) + .build(); + + @Test + void reconcilesExternalResourceWithWorkflowStoringStateInStatus() { + var resource = operator.create(testResource()); + assertResourceCreated(INITIAL_TEST_DATA); + + resource.getSpec().setData(UPDATED_DATA); + operator.replace(resource); + assertResourceCreated(UPDATED_DATA); + + operator.delete(resource); + assertResourceDeleted(); + } + + private void assertResourceCreated(String expectedData) { + await() + .untilAsserted( + () -> { + var resources = externalService.listResources(); + // exactly one external resource is created, no duplicates + assertThat(resources).hasSize(1); + var extRes = resources.get(0); + assertThat(extRes.getData()).isEqualTo(expectedData); + + var cr = + operator.get( + ExternalStateInStatusWorkflowCustomResource.class, TEST_RESOURCE_NAME); + assertThat(cr.getStatus()).isNotNull(); + // the external resource state (its ID) is stored in the status + assertThat(cr.getStatus().getId()).isEqualTo(extRes.getId()); + }); + } + + private void assertResourceDeleted() { + await().untilAsserted(() -> assertThat(externalService.listResources()).isEmpty()); + } + + private ExternalStateInStatusWorkflowCustomResource testResource() { + var res = new ExternalStateInStatusWorkflowCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new ExternalStateInStatusSpec().setData(INITIAL_TEST_DATA)); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowReconciler.java new file mode 100644 index 0000000000..26687e0ba0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstateinstatus/ExternalStateInStatusWorkflowReconciler.java @@ -0,0 +1,71 @@ +/* + * 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.dependent.externalstateinstatus; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.ExternalResource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * Manages an external resource through a managed workflow with a single {@link + * ExternalStateInStatusDependentResource dependent resource}, while storing the external resource's + * state - its generated ID - in the status of the custom resource. + * + *

The managed workflow reconciles the dependent (creating the external resource) just before + * this {@code reconcile} method runs. The reconciler then persists the external ID into the status + * with {@link UpdateControl#patchStatus(io.fabric8.kubernetes.api.model.HasMetadata)}. Thanks to + * the stronger read-after-write consistency for updates, the patched status is placed into the + * cache, so the next reconciliation - and the dependent's fetch - observe the ID and do not create + * a duplicate external resource. + */ +@Workflow(dependents = @Dependent(type = ExternalStateInStatusDependentResource.class)) +@ControllerConfiguration +public class ExternalStateInStatusWorkflowReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ExternalStateInStatusWorkflowCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + var externalResource = context.getSecondaryResource(ExternalResource.class); + if (externalResource.isEmpty()) { + return UpdateControl.noUpdate(); + } + + var id = externalResource.orElseThrow().getId(); + if (resource.getStatus() == null || !id.equals(resource.getStatus().getId())) { + resource.setStatus(new ExternalStateInStatusStatus().setId(id)); + return UpdateControl.patchStatus(resource); + } + return UpdateControl.noUpdate(); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceReconciler.java index bc3129809f..b1e187dcd9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceReconciler.java @@ -54,8 +54,7 @@ public List> prepareEven new InformerEventSource<>( InformerEventSourceConfiguration.from( ConfigMap.class, MultipleDependentResourceCustomResource.class) - .build(), - context); + .build()); firstDependentResourceConfigMap.setEventSource(eventSource); secondDependentResourceConfigMap.setEventSource(eventSource); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithDiscriminatorReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithDiscriminatorReconciler.java index 93106451b3..2bc1d6bd30 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithDiscriminatorReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithDiscriminatorReconciler.java @@ -66,8 +66,7 @@ public int getNumberOfExecutions() { InformerEventSourceConfiguration.from( ConfigMap.class, MultipleDependentResourceCustomResourceNoDiscriminator.class) - .build(), - context); + .build()); firstDependentResourceConfigMap.setEventSource(eventSource); secondDependentResourceConfigMap.setEventSource(eventSource); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentSameTypeNoDiscriminatorReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentSameTypeNoDiscriminatorReconciler.java index 21c6e39d6e..03425cdba0 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentSameTypeNoDiscriminatorReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentSameTypeNoDiscriminatorReconciler.java @@ -71,8 +71,7 @@ public int getNumberOfExecutions() { InformerEventSourceConfiguration.from( ConfigMap.class, MultipleManagedDependentNoDiscriminatorCustomResource.class) .withName(CONFIG_MAP_EVENT_SOURCE) - .build(), - context); + .build()); return List.of(ies); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceReconciler.java index c792f81532..b26e76e458 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceReconciler.java @@ -70,8 +70,7 @@ public List> prep InformerEventSourceConfiguration.from( ConfigMap.class, MultipleManagedDependentResourceCustomResource.class) .withName(CONFIG_MAP_EVENT_SOURCE) - .build(), - context); + .build()); return List.of(ies); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerTestReconciler.java index c4d61a1a29..49f49d7a69 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerTestReconciler.java @@ -67,8 +67,7 @@ public List> prepareEventSource .stream() .map(ResourceID::fromResource) .collect(Collectors.toSet())) - .build(), - context); + .build()); return List.of(es); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentReconciler.java index 45b6777c88..37951c820e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentReconciler.java @@ -93,7 +93,7 @@ public List> prepareEv primary.getMetadata().getNamespace())))); var es = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from( ConfigMap.class, PrimaryToSecondaryDependentCustomResource.class) .withName(CONFIG_MAP_EVENT_SOURCE) @@ -120,8 +120,7 @@ public List> prepareEv .stream() .map(ResourceID::fromResource) .collect(Collectors.toSet())) - .build(), - context); + .build()); return List.of(es); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java index a44b3e13f3..52cbd79f52 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java @@ -28,6 +28,7 @@ import io.javaoperatorsdk.operator.api.config.*; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.informer.pool.AbstractInformerPool; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -127,6 +128,12 @@ public Set getKnownReconcilerNames() { public Version getVersion() { return null; } + + // only used here to obtain the resource cloner, so the pool is never accessed + @Override + public AbstractInformerPool informerPool() { + return null; + } }.getResourceCloner(); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowReconciler.java index f7ab9c08df..c491aa094a 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowReconciler.java @@ -89,15 +89,13 @@ public List> prepareEventSources( InformerEventSourceConfiguration.from( Service.class, ComplexWorkflowCustomResource.class) .withName(SERVICE_EVENT_SOURCE_NAME) - .build(), - context); + .build()); InformerEventSource statefulSetEventSource = new InformerEventSource<>( InformerEventSourceConfiguration.from( StatefulSet.class, ComplexWorkflowCustomResource.class) .withName(STATEFUL_SET_EVENT_SOURCE_NAME) - .build(), - context); + .build()); return List.of(serviceEventSource, statefulSetEventSource); } diff --git a/pom.xml b/pom.xml index 6d333ed3a1..88a67f6e63 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.2-SNAPSHOT + 999-SNAPSHOT pom Operator SDK for Java Java SDK for implementing Kubernetes operators @@ -70,17 +70,18 @@ java-operator-sdk https://sonarcloud.io jdk - 6.1.3 + 6.1.2 7.8.0 2.0.18 2.26.1 5.23.0 3.20.0 0.23.0 + 1.13.0 3.27.7 4.3.0 2.7.3 - 1.17.1 + 1.17.0 3.2.4 0.9.14 2.22.0 @@ -93,7 +94,7 @@ 3.12.0 3.5.0 3.4.0 - 3.5.1 + 3.5.0 3.5.0 3.2.8 1.7.0 @@ -101,8 +102,8 @@ 3.1.4 10.0.0 3.5.2 - 3.9.0 - 4.10.4.0 + 3.8.0 + 4.10.3.0 @@ -147,6 +148,11 @@ micrometer-core ${micrometer-core.version} + + com.squareup + javapoet + ${javapoet.version} + org.awaitility awaitility @@ -384,7 +390,7 @@ com.mycila license-maven-plugin - 5.1.2 + 5.1.1 true @@ -636,20 +642,6 @@ true true published - - - sample-operators - sample-controller-namespace-deletion - sample-kotlin-operator - sample-leader-election - sample-mysql-schema-operator - sample-operations - sample-tomcat-operator - sample-webpage-operator - diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index fa8326c50b..af4be01972 100644 --- a/sample-operators/controller-namespace-deletion/pom.xml +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-controller-namespace-deletion diff --git a/sample-operators/kotlin-operator/pom.xml b/sample-operators/kotlin-operator/pom.xml index bd76447e79..a5ca180cd2 100644 --- a/sample-operators/kotlin-operator/pom.xml +++ b/sample-operators/kotlin-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-kotlin-operator diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index 916d30ed4d..4f896485d1 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-leader-election diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index 45e4738af3..d2872c921a 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-mysql-schema-operator diff --git a/sample-operators/operations/pom.xml b/sample-operators/operations/pom.xml index 75f7ac1384..14a0154922 100644 --- a/sample-operators/operations/pom.xml +++ b/sample-operators/operations/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-operations diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index ed35a1ed91..704007c076 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-operators diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index c80cb02cb1..ea964a2b07 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-tomcat-operator diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java index 9ab98d84c2..d254809864 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java @@ -84,7 +84,7 @@ public List> prepareEventSources(EventSourceContext(configuration, context)); + return List.of(new InformerEventSource<>(configuration)); } /** diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 7bdf58ad63..d50e5ef03c 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.5.2-SNAPSHOT + 999-SNAPSHOT sample-webpage-operator diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index eba68d9381..ab22e6f071 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -53,29 +53,25 @@ public WebPageReconciler() {} @Override public List> prepareEventSources(EventSourceContext context) { var configMapEventSource = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from(ConfigMap.class, WebPage.class) .withLabelSelector(SELECTOR) - .build(), - context); + .build()); var deploymentEventSource = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from(Deployment.class, WebPage.class) .withLabelSelector(SELECTOR) - .build(), - context); + .build()); var serviceEventSource = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from(Service.class, WebPage.class) .withLabelSelector(SELECTOR) - .build(), - context); + .build()); var ingressEventSource = - new InformerEventSource<>( + new InformerEventSource( InformerEventSourceConfiguration.from(Ingress.class, WebPage.class) .withLabelSelector(SELECTOR) - .build(), - context); + .build()); return List.of( configMapEventSource, deploymentEventSource, serviceEventSource, ingressEventSource); } diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml index 2ce3234fd2..2ae7c5f454 100644 --- a/test-index-processor/pom.xml +++ b/test-index-processor/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.5.2-SNAPSHOT + 999-SNAPSHOT test-index-processor