diff --git a/docs/documentation/features.md b/docs/documentation/features.md index b4cbd1f2fd..d8e1cfa642 100644 --- a/docs/documentation/features.md +++ b/docs/documentation/features.md @@ -711,7 +711,29 @@ setting, where this flag usually needs to be set to false, in order to control t See also an example implementation in the [WebPage sample](https://github.com/java-operator-sdk/java-operator-sdk/blob/3e2e7c4c834ef1c409d636156b988125744ca911/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java#L38-L43) -## Monitoring with Micrometer +## Optimization of Caches + +** Cache pruning is an experimental feature. Might a subject of change or even removal in the future. ** + +Operators using informers will initially cache the data for all known resources when starting up +so that access to resources can be performed quickly. Consequently, the memory required for the +operator to run and startup time will both increase quite dramatically when dealing with large +clusters with numerous resources. + +It is thus possible to configure the operator to cache only pruned versions of the resources to +alleviate the memory usage of the primary and secondary caches. This setup, however, has +implications on how reconcilers deal with resources since they will only work with partial +objects. As a consequence, resources need to be updated using PATCH operations only, sending +only required changes. + +To see how to use, and how to handle related caveats regarding how to deal with pruned objects +that leverage +[server side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) patches, +please check the provided +[integration test](https://github.com/java-operator-sdk/java-operator-sdk/blob/c688524e64205690ba15587e7ed96a64dc231430/operator-framework/src/test/java/io/javaoperatorsdk/operator/CachePruneIT.java) +and associates reconciler. + +Pruned caches are currently not supported with the Dependent Resources feature. ## Automatic Generation of CRDs diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationControllerConfiguration.java index 43c61319ac..b996eeead9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationControllerConfiguration.java @@ -8,6 +8,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.stream.Collectors; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -83,6 +84,14 @@ public Set getNamespaces() { DEFAULT_NAMESPACES_SET.toArray(String[]::new))); } + @Override + @SuppressWarnings("unchecked") + public Optional> cachePruneFunction() { + return Optional.ofNullable( + Utils.instantiate(annotation.cachePruneFunction(), UnaryOperator.class, + Utils.contextFor(this, null, null))); + } + @Override @SuppressWarnings("unchecked") public Class

getResourceClass() { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java index c36aa51d2e..1f5494050e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; import java.util.stream.Collectors; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -38,6 +39,7 @@ public class ControllerConfigurationOverrider { private OnUpdateFilter onUpdateFilter; private GenericFilter genericFilter; private RateLimiter rateLimiter; + private UnaryOperator cachePruneFunction; private ControllerConfigurationOverrider(ControllerConfiguration original) { finalizer = original.getFinalizerName(); @@ -56,6 +58,7 @@ private ControllerConfigurationOverrider(ControllerConfiguration original) { dependentResources.forEach(drs -> namedDependentResourceSpecs.put(drs.getName(), drs)); this.original = original; this.rateLimiter = original.getRateLimiter(); + this.cachePruneFunction = original.cachePruneFunction().orElse(null); } public ControllerConfigurationOverrider withFinalizer(String finalizer) { @@ -158,6 +161,12 @@ public ControllerConfigurationOverrider withGenericFilter(GenericFilter ge return this; } + public ControllerConfigurationOverrider withCachePruneFunction( + UnaryOperator cachePruneFunction) { + this.cachePruneFunction = cachePruneFunction; + return this; + } + @SuppressWarnings("unchecked") public ControllerConfigurationOverrider replacingNamedDependentResourceConfig(String name, Object dependentResourceConfig) { @@ -208,7 +217,7 @@ public ControllerConfiguration build() { onUpdateFilter, genericFilter, rateLimiter, - newDependentSpecs); + newDependentSpecs, cachePruneFunction); } public static ControllerConfigurationOverrider override( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java index 3f4d952133..f6277c4139 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; @@ -49,8 +50,10 @@ public DefaultControllerConfiguration( OnUpdateFilter onUpdateFilter, GenericFilter genericFilter, RateLimiter rateLimiter, - List dependents) { - super(labelSelector, resourceClass, onAddFilter, onUpdateFilter, genericFilter, namespaces); + List dependents, + UnaryOperator cachePruneFunction) { + super(labelSelector, resourceClass, onAddFilter, onUpdateFilter, genericFilter, namespaces, + cachePruneFunction); this.associatedControllerClassName = associatedControllerClassName; this.name = name; this.crdName = crdName; @@ -116,4 +119,5 @@ public Optional maxReconciliationInterval() { public RateLimiter getRateLimiter() { return rateLimiter; } + } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultResourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultResourceConfiguration.java index 9bc6ce5dba..8e42284a25 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultResourceConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultResourceConfiguration.java @@ -2,6 +2,7 @@ import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter; @@ -19,18 +20,23 @@ public class DefaultResourceConfiguration private final OnAddFilter onAddFilter; private final OnUpdateFilter onUpdateFilter; private final GenericFilter genericFilter; + private final UnaryOperator cachePruneFunction; public DefaultResourceConfiguration(String labelSelector, Class resourceClass, OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, GenericFilter genericFilter, String... namespaces) { this(labelSelector, resourceClass, onAddFilter, onUpdateFilter, genericFilter, namespaces == null || namespaces.length == 0 ? DEFAULT_NAMESPACES_SET - : Set.of(namespaces)); + : Set.of(namespaces), + null); } public DefaultResourceConfiguration(String labelSelector, Class resourceClass, OnAddFilter onAddFilter, - OnUpdateFilter onUpdateFilter, GenericFilter genericFilter, Set namespaces) { + OnUpdateFilter onUpdateFilter, + GenericFilter genericFilter, + Set namespaces, + UnaryOperator cachePruneFunction) { this.labelSelector = labelSelector; this.resourceClass = resourceClass; this.onAddFilter = onAddFilter; @@ -39,6 +45,7 @@ public DefaultResourceConfiguration(String labelSelector, Class resourceClass this.namespaces = namespaces == null || namespaces.isEmpty() ? DEFAULT_NAMESPACES_SET : namespaces; + this.cachePruneFunction = cachePruneFunction; } @Override @@ -56,6 +63,11 @@ public Set getNamespaces() { return namespaces; } + @Override + public Optional> cachePruneFunction() { + return Optional.ofNullable(this.cachePruneFunction); + } + @Override public Class getResourceClass() { return resourceClass; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResourceConfiguration.java index 90e18f3e52..6a6574182a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResourceConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResourceConfiguration.java @@ -3,11 +3,13 @@ import java.util.Collections; import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.ReconcilerUtils; import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; @@ -108,4 +110,11 @@ default Set getEffectiveNamespaces() { } return targetNamespaces; } + + /** + * See {@link ControllerConfiguration#cachePruneFunction()} for details. + */ + default Optional> cachePruneFunction() { + return Optional.empty(); + } } 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 52f71501a9..03e37863df 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 @@ -3,6 +3,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.DefaultResourceConfiguration; @@ -29,6 +30,7 @@ class DefaultInformerConfiguration extends private final SecondaryToPrimaryMapper secondaryToPrimaryMapper; private final boolean followControllerNamespaceChanges; private final OnDeleteFilter onDeleteFilter; + private final UnaryOperator cachePruneFunction; protected DefaultInformerConfiguration(String labelSelector, Class resourceClass, @@ -38,8 +40,10 @@ protected DefaultInformerConfiguration(String labelSelector, OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, OnDeleteFilter onDeleteFilter, - GenericFilter genericFilter) { - super(labelSelector, resourceClass, onAddFilter, onUpdateFilter, genericFilter, namespaces); + GenericFilter genericFilter, + UnaryOperator cachePruneFunction) { + super(labelSelector, resourceClass, onAddFilter, onUpdateFilter, genericFilter, namespaces, + cachePruneFunction); this.followControllerNamespaceChanges = followControllerNamespaceChanges; this.primaryToSecondaryMapper = primaryToSecondaryMapper; @@ -47,6 +51,7 @@ protected DefaultInformerConfiguration(String labelSelector, Objects.requireNonNullElse(secondaryToPrimaryMapper, Mappers.fromOwnerReference()); this.onDeleteFilter = onDeleteFilter; + this.cachePruneFunction = cachePruneFunction; } @Override @@ -67,6 +72,11 @@ public Optional> onDeleteFilter() { public

PrimaryToSecondaryMapper

getPrimaryToSecondaryMapper() { return (PrimaryToSecondaryMapper

) primaryToSecondaryMapper; } + + @Override + public Optional> cachePruneFunction() { + return Optional.ofNullable(this.cachePruneFunction); + } } /** @@ -102,6 +112,7 @@ class InformerConfigurationBuilder { private OnDeleteFilter onDeleteFilter; private GenericFilter genericFilter; private boolean inheritControllerNamespacesOnChange = false; + private UnaryOperator cachePruneFunction; private InformerConfigurationBuilder(Class resourceClass) { this.resourceClass = resourceClass; @@ -202,12 +213,18 @@ public InformerConfigurationBuilder withGenericFilter(GenericFilter generi return this; } + public InformerConfigurationBuilder withCachePruneFunction( + UnaryOperator cachePruneFunction) { + this.cachePruneFunction = cachePruneFunction; + return this; + } + public InformerConfiguration build() { return new DefaultInformerConfiguration<>(labelSelector, resourceClass, primaryToSecondaryMapper, secondaryToPrimaryMapper, namespaces, inheritControllerNamespacesOnChange, onAddFilter, onUpdateFilter, - onDeleteFilter, genericFilter); + onDeleteFilter, genericFilter, cachePruneFunction); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java index ec76adf89d..df3efa7d40 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java @@ -5,6 +5,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.function.UnaryOperator; import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; @@ -118,4 +119,25 @@ MaxReconciliationInterval maxReconciliationInterval() default @MaxReconciliation * accessible no-arg constructor. */ Class rateLimiter() default LinearRateLimiter.class; + + /** + *

+ * This is an experimental feature, might be a subject of change and even removal in the + * future. + *

+ *

+ * In order to optimize cache, thus set null on some attributes, this function can be set. Note + * that this has subtle implications how updates on the resources should be handled. Notably only + * patching of the resource can be used from that point, since update would remove not cached + * parts of the resource. + *

+ *

+ * Note that this feature does not work with Dependent Resources. + *

+ * + * + * + * @return function to remove parts of the resource. + */ + Class cachePruneFunction() default UnaryOperator.class; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java index 015a2f5c31..4a7ac68082 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -1,5 +1,7 @@ package io.javaoperatorsdk.operator.processing.event; +import java.util.function.Function; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,6 +12,8 @@ import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.dsl.Resource; +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.api.ObservedGenerationAware; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceProvider; @@ -82,7 +86,7 @@ private PostExecutionControl

handleDispatch(ExecutionScope

executionScope) Context

context = new DefaultContext<>(executionScope.getRetryInfo(), controller, originalResource); if (markedForDeletion) { - return handleCleanup(resourceForExecution, context); + return handleCleanup(originalResource, resourceForExecution, context); } else { return handleReconcile(executionScope, resourceForExecution, originalResource, context); } @@ -109,7 +113,8 @@ private PostExecutionControl

handleReconcile( * finalizer add. This will make sure that the resources are not created before there is a * finalizer. */ - var updatedResource = updateCustomResourceWithFinalizer(originalResource); + var updatedResource = + updateCustomResourceWithFinalizer(resourceForExecution, originalResource); return PostExecutionControl.onlyFinalizerAdded(updatedResource); } else { try { @@ -276,7 +281,8 @@ private void updatePostExecutionControlWithReschedule( } - private PostExecutionControl

handleCleanup(P resource, Context

context) { + private PostExecutionControl

handleCleanup(P originalResource, P resource, + Context

context) { log.debug( "Executing delete for resource: {} with version: {}", getName(resource), @@ -289,7 +295,8 @@ private PostExecutionControl

handleCleanup(P resource, Context

context) { // cleanup is finished, nothing left to done final var finalizerName = configuration().getFinalizerName(); if (deleteControl.isRemoveFinalizer() && resource.hasFinalizer(finalizerName)) { - P customResource = removeFinalizer(resource, finalizerName); + P customResource = conflictRetryingPatch(resource, originalResource, + r -> r.removeFinalizer(finalizerName)); return PostExecutionControl.customResourceFinalizerRemoved(customResource); } } @@ -304,11 +311,13 @@ private PostExecutionControl

handleCleanup(P resource, Context

context) { return postExecutionControl; } - private P updateCustomResourceWithFinalizer(P resource) { + private P updateCustomResourceWithFinalizer(P resourceForExecution, P originalResource) { log.debug( - "Adding finalizer for resource: {} version: {}", getUID(resource), getVersion(resource)); - resource.addFinalizer(configuration().getFinalizerName()); - return customResourceFacade.updateResource(resource); + "Adding finalizer for resource: {} version: {}", getUID(originalResource), + getVersion(originalResource)); + + return conflictRetryingPatch(resourceForExecution, originalResource, + r -> r.addFinalizer(configuration().getFinalizerName())); } private P updateCustomResource(P resource) { @@ -321,20 +330,21 @@ ControllerConfiguration

configuration() { return controller.getConfiguration(); } - public P removeFinalizer(P resource, String finalizer) { + public P conflictRetryingPatch(P resource, P originalResource, + Function modificationFunction) { if (log.isDebugEnabled()) { log.debug("Removing finalizer on resource: {}", ResourceID.fromResource(resource)); } int retryIndex = 0; while (true) { try { - var removed = resource.removeFinalizer(finalizer); - if (!removed) { + var modified = modificationFunction.apply(resource); + if (Boolean.FALSE.equals(modified)) { return resource; } - return customResourceFacade.updateResource(resource); + return customResourceFacade.serverSideApplyLockResource(resource, originalResource); } catch (KubernetesClientException e) { - log.trace("Exception during finalizer removal for resource: {}", resource); + log.trace("Exception during patch for resource: {}", resource); retryIndex++; // only retry on conflict (HTTP 409), otherwise fail if (e.getCode() != 409) { @@ -343,7 +353,7 @@ public P removeFinalizer(P resource, String finalizer) { if (retryIndex >= MAX_FINALIZER_REMOVAL_RETRY) { throw new OperatorException( "Exceeded maximum (" + MAX_FINALIZER_REMOVAL_RETRY - + ") retry attempts to remove finalizer '" + finalizer + "' for resource " + + ") retry attempts to patch resource: " + ResourceID.fromResource(resource)); } resource = customResourceFacade.getResource(resource.getMetadata().getNamespace(), @@ -370,12 +380,18 @@ public R getResource(String namespace, String name) { } } + public R serverSideApplyLockResource(R resource, R originalResource) { + var patchContext = PatchContext.of(PatchType.SERVER_SIDE_APPLY); + patchContext.setForce(true); + return resource(originalResource).patch(patchContext, + resource); + } + public R updateResource(R resource) { log.debug( "Trying to replace resource {}, version: {}", getName(resource), resource.getMetadata().getResourceVersion()); - return resource(resource).lockResourceVersion(resource.getMetadata().getResourceVersion()) .replace(); } 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 d51301c385..94788e6706 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 @@ -109,8 +109,11 @@ private InformerWrapper createEventSourceForNamespace(String namespace) { private InformerWrapper createEventSource( FilterWatchListDeletable, Resource> filteredBySelectorClient, ResourceEventHandler eventHandler, String namespaceIdentifier) { + var informer = filteredBySelectorClient.runnableInformer(0); + configuration.cachePruneFunction() + .ifPresent(f -> informer.itemStore(new TransformingItemStore<>(f))); var source = - new InformerWrapper<>(filteredBySelectorClient.runnableInformer(0), namespaceIdentifier); + new InformerWrapper<>(informer, namespaceIdentifier); source.addEventHandler(eventHandler); sources.put(namespaceIdentifier, source); return source; 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 8809022e95..17dc5cc969 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 @@ -39,7 +39,6 @@ public InformerWrapper(SharedIndexInformer informer, String namespaceIdentifi this.informer = informer; this.namespaceIdentifier = namespaceIdentifier; this.cache = (Cache) informer.getStore(); - } @Override 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 43173a28f7..4286335644 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 @@ -36,13 +36,15 @@ public abstract class ManagedInformerEventSource temporaryResourceCache = new TemporaryResourceCache<>(this); + protected TemporaryResourceCache temporaryResourceCache; protected InformerManager cache = new InformerManager<>(); protected C configuration; protected ManagedInformerEventSource( MixedOperation, Resource> client, C configuration) { super(configuration.getResourceClass()); + temporaryResourceCache = new TemporaryResourceCache<>(this, + configuration.cachePruneFunction().orElse(null)); manager().initSources(client, configuration, this); this.configuration = configuration; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index c0c041f3fb..0af2ec0b2f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -3,6 +3,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.UnaryOperator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,11 +35,14 @@ public class TemporaryResourceCache { private static final Logger log = LoggerFactory.getLogger(TemporaryResourceCache.class); + private UnaryOperator cachePruneFunction; private final Map cache = new ConcurrentHashMap<>(); private final ManagedInformerEventSource managedInformerEventSource; - public TemporaryResourceCache(ManagedInformerEventSource managedInformerEventSource) { + public TemporaryResourceCache(ManagedInformerEventSource managedInformerEventSource, + UnaryOperator cachePruneFunction) { this.managedInformerEventSource = managedInformerEventSource; + this.cachePruneFunction = cachePruneFunction; } public synchronized void removeResourceFromCache(T resource) { @@ -46,14 +50,14 @@ public synchronized void removeResourceFromCache(T resource) { } public synchronized void unconditionallyCacheResource(T newResource) { - cache.put(ResourceID.fromResource(newResource), newResource); + putToCache(newResource, null); } public synchronized void putAddedResource(T newResource) { ResourceID resourceID = ResourceID.fromResource(newResource); if (managedInformerEventSource.get(resourceID).isEmpty()) { log.debug("Putting resource to cache with ID: {}", resourceID); - cache.put(resourceID, newResource); + putToCache(newResource, resourceID); } else { log.debug("Won't put resource into cache found already informer cache: {}", resourceID); } @@ -70,7 +74,7 @@ public synchronized void putUpdatedResource(T newResource, String previousResour if (informerCacheResource.get().getMetadata().getResourceVersion() .equals(previousResourceVersion)) { log.debug("Putting resource to temporal cache with id: {}", resourceId); - cache.put(resourceId, newResource); + putToCache(newResource, resourceId); } else { // if something is in cache it's surely obsolete now log.debug("Trying to remove an obsolete resource from cache for id: {}", resourceId); @@ -78,6 +82,13 @@ public synchronized void putUpdatedResource(T newResource, String previousResour } } + private void putToCache(T resource, ResourceID resourceID) { + if (cachePruneFunction != null) { + resource = cachePruneFunction.apply(resource); + } + cache.put(resourceID == null ? ResourceID.fromResource(resource) : resourceID, resource); + } + public synchronized Optional getResourceFromCache(ResourceID resourceID) { return Optional.ofNullable(cache.get(resourceID)); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStore.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStore.java new file mode 100644 index 0000000000..60fdd32005 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStore.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.fabric8.kubernetes.client.informers.cache.ItemStore; + +public class TransformingItemStore implements ItemStore { + + private Function keyFunction; + private UnaryOperator transformationFunction; + private ConcurrentHashMap store = new ConcurrentHashMap<>(); + + public TransformingItemStore(UnaryOperator transformationFunction) { + this(Cache::metaNamespaceKeyFunc, transformationFunction); + } + + public TransformingItemStore(Function keyFunction, + UnaryOperator transformationFunction) { + this.keyFunction = keyFunction; + this.transformationFunction = transformationFunction; + } + + @Override + public String getKey(R obj) { + return keyFunction.apply(obj); + } + + @Override + public R put(String key, R obj) { + var originalName = obj.getMetadata().getName(); + var originalNamespace = obj.getMetadata().getNamespace(); + var originalResourceVersion = obj.getMetadata().getResourceVersion(); + + var transformed = transformationFunction.apply(obj); + + transformed.getMetadata().setName(originalName); + transformed.getMetadata().setNamespace(originalNamespace); + transformed.getMetadata().setResourceVersion(originalResourceVersion); + return store.put(key, transformed); + } + + @Override + public R remove(String key) { + return store.remove(key); + } + + @Override + public Stream keySet() { + return store.keySet().stream(); + } + + @Override + public Stream values() { + return store.values().stream(); + } + + @Override + public R get(String key) { + return store.get(key); + } + + @Override + public int size() { + return store.size(); + } + + @Override + public boolean isFullState() { + return false; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java index d788f61e4a..94af637b38 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java @@ -58,7 +58,7 @@ private static class TestControllerConfiguration public TestControllerConfiguration(Reconciler controller, Class crClass) { super(null, getControllerName(controller), CustomResource.getCRDName(crClass), null, false, null, null, null, null, crClass, - null, null, null, null, null, null); + null, null, null, null, null, null, null); this.controller = 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 892fffcdbb..bdda20f08b 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 @@ -22,10 +22,7 @@ import io.javaoperatorsdk.operator.MockKubernetesClient; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.TestUtils; -import io.javaoperatorsdk.operator.api.config.Cloner; -import io.javaoperatorsdk.operator.api.config.ConfigurationServiceProvider; -import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.config.MockControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.*; import io.javaoperatorsdk.operator.api.reconciler.Cleaner; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; @@ -137,8 +134,9 @@ void addFinalizerOnNewResource() { verify(reconciler, never()) .reconcile(ArgumentMatchers.eq(testCustomResource), any()); verify(customResourceFacade, times(1)) - .updateResource( - argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER))); + .serverSideApplyLockResource( + argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER)), + any()); assertThat(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)).isTrue(); } @@ -218,7 +216,8 @@ void removesDefaultFinalizerOnDeleteIfSet() { reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); assertThat(postExecControl.isFinalizerRemoved()).isTrue(); - verify(customResourceFacade, times(1)).updateResource(testCustomResource); + verify(customResourceFacade, times(1)).serverSideApplyLockResource(testCustomResource, + testCustomResource); } @Test @@ -227,7 +226,7 @@ void retriesFinalizerRemovalWithFreshResource() { markForDeletion(testCustomResource); var resourceWithFinalizer = TestUtils.testCustomResource(); resourceWithFinalizer.addFinalizer(DEFAULT_FINALIZER); - when(customResourceFacade.updateResource(testCustomResource)) + when(customResourceFacade.serverSideApplyLockResource(testCustomResource, testCustomResource)) .thenThrow(new KubernetesClientException(null, 409, null)) .thenReturn(testCustomResource); when(customResourceFacade.getResource(any(), any())).thenReturn(resourceWithFinalizer); @@ -236,7 +235,7 @@ void retriesFinalizerRemovalWithFreshResource() { reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); assertThat(postExecControl.isFinalizerRemoved()).isTrue(); - verify(customResourceFacade, times(2)).updateResource(any()); + verify(customResourceFacade, times(2)).serverSideApplyLockResource(any(), any()); verify(customResourceFacade, times(1)).getResource(any(), any()); } @@ -244,7 +243,7 @@ void retriesFinalizerRemovalWithFreshResource() { void throwsExceptionIfFinalizerRemovalRetryExceeded() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); markForDeletion(testCustomResource); - when(customResourceFacade.updateResource(any())) + when(customResourceFacade.serverSideApplyLockResource(any(), any())) .thenThrow(new KubernetesClientException(null, 409, null)); when(customResourceFacade.getResource(any(), any())) .thenAnswer((Answer) invocationOnMock -> createResourceWithFinalizer()); @@ -256,7 +255,9 @@ void throwsExceptionIfFinalizerRemovalRetryExceeded() { assertThat(postExecControl.getRuntimeException()).isPresent(); assertThat(postExecControl.getRuntimeException().get()) .isInstanceOf(OperatorException.class); - verify(customResourceFacade, times(MAX_FINALIZER_REMOVAL_RETRY)).updateResource(any()); + verify(customResourceFacade, times(MAX_FINALIZER_REMOVAL_RETRY)).serverSideApplyLockResource( + any(), + any()); verify(customResourceFacade, times(MAX_FINALIZER_REMOVAL_RETRY - 1)).getResource(any(), any()); } @@ -265,7 +266,7 @@ void throwsExceptionIfFinalizerRemovalRetryExceeded() { void throwsExceptionIfFinalizerRemovalClientExceptionIsNotConflict() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); markForDeletion(testCustomResource); - when(customResourceFacade.updateResource(any())) + when(customResourceFacade.serverSideApplyLockResource(any(), any())) .thenThrow(new KubernetesClientException(null, 400, null)); var res = @@ -273,7 +274,7 @@ void throwsExceptionIfFinalizerRemovalClientExceptionIsNotConflict() { assertThat(res.getRuntimeException()).isPresent(); assertThat(res.getRuntimeException().get()).isInstanceOf(KubernetesClientException.class); - verify(customResourceFacade, times(1)).updateResource(any()); + verify(customResourceFacade, times(1)).serverSideApplyLockResource(any(), any()); verify(customResourceFacade, never()).getResource(any(), any()); } @@ -337,13 +338,14 @@ void doesNotUpdateTheResourceIfNoUpdateUpdateControlIfFinalizerSet() { void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() { removeFinalizers(testCustomResource); reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); - when(customResourceFacade.updateResource(any())).thenReturn(testCustomResource); + when(customResourceFacade.serverSideApplyLockResource(any(), any())) + .thenReturn(testCustomResource); var postExecControl = reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); assertEquals(1, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceFacade, times(1)).updateResource(any()); + verify(customResourceFacade, times(1)).serverSideApplyLockResource(any(), any()); assertThat(postExecControl.updateIsStatusPatch()).isFalse(); assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); } @@ -646,6 +648,24 @@ void canSkipSchedulingMaxDelayIf() { assertThat(control.getReScheduleDelay()).isNotPresent(); } + @Test + void retriesAddingFinalizer() { + removeFinalizers(testCustomResource); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + when(customResourceFacade.serverSideApplyLockResource(any(), any())) + .thenThrow(new KubernetesClientException(null, 409, null)) + .thenReturn(testCustomResource); + when(customResourceFacade.getResource(any(), any())) + .then((Answer) invocationOnMock -> { + testCustomResource.getFinalizers().clear(); + return testCustomResource; + }); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(customResourceFacade, times(2)).serverSideApplyLockResource(any(), any()); + } + private ObservedGenCustomResource createObservedGenCustomResource() { ObservedGenCustomResource observedGenCustomResource = new ObservedGenCustomResource(); observedGenCustomResource.setMetadata(new ObjectMeta()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventFilterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventFilterTest.java index 7cc5a20781..55ab40c173 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventFilterTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventFilterTest.java @@ -145,7 +145,7 @@ public ControllerConfig(String finalizer, boolean generationAware, eventFilter, customResourceClass, null, - null, null, null, null, null); + null, null, null, null, null, null); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSourceTest.java index 00980743e0..d30a69d694 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSourceTest.java @@ -188,7 +188,7 @@ public TestConfiguration(boolean generationAware, OnAddFilter informerEventSource = mock(InformerEventSource.class); private TemporaryResourceCache temporaryResourceCache = - new TemporaryResourceCache<>(informerEventSource); + new TemporaryResourceCache<>(informerEventSource, null); @Test @@ -79,6 +80,29 @@ void removesResourceFromCache() { .isNotPresent(); } + @Test + void objectIsTransformedBeforePutIntoCache() { + temporaryResourceCache = + new TemporaryResourceCache<>(informerEventSource, r -> { + r.getMetadata().setLabels(null); + return r; + }); + + temporaryResourceCache.putAddedResource(testResource()); + assertLabelsIsEmpty(temporaryResourceCache); + + temporaryResourceCache.unconditionallyCacheResource(testResource()); + assertLabelsIsEmpty(temporaryResourceCache); + + temporaryResourceCache.unconditionallyCacheResource(testResource()); + assertLabelsIsEmpty(temporaryResourceCache); + } + + private void assertLabelsIsEmpty(TemporaryResourceCache temporaryResourceCache) { + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource())) + .orElseThrow().getMetadata().getLabels()).isNull(); + } + private ConfigMap propagateTestResourceToCache() { var testResource = testResource(); when(informerEventSource.get(any())).thenReturn(Optional.empty()); @@ -90,7 +114,9 @@ private ConfigMap propagateTestResourceToCache() { ConfigMap testResource() { ConfigMap configMap = new ConfigMap(); - configMap.setMetadata(new ObjectMeta()); + configMap.setMetadata(new ObjectMetaBuilder() + .withLabels(Map.of("k", "v")) + .build()); configMap.getMetadata().setName("test"); configMap.getMetadata().setNamespace("default"); configMap.getMetadata().setResourceVersion(RESOURCE_VERSION); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStoreTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStoreTest.java new file mode 100644 index 0000000000..3bebc79094 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStoreTest.java @@ -0,0 +1,59 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; + +import static io.fabric8.kubernetes.client.informers.cache.Cache.metaNamespaceKeyFunc; +import static org.assertj.core.api.Assertions.assertThat; + +class TransformingItemStoreTest { + + @Test + void cachedObjectTransformed() { + TransformingItemStore transformingItemStore = new TransformingItemStore<>(r -> { + r.getMetadata().setLabels(null); + return r; + }); + + var cm = configMap(); + cm.getMetadata().setLabels(Map.of("k", "v")); + transformingItemStore.put(metaNamespaceKeyFunc(cm), cm); + + assertThat(transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getLabels()) + .isNull(); + } + + @Test + void preservesSelectedAttributes() { + TransformingItemStore transformingItemStore = new TransformingItemStore<>(r -> { + r.getMetadata().setName(null); + r.getMetadata().setNamespace(null); + r.getMetadata().setResourceVersion(null); + return r; + }); + var cm = configMap(); + transformingItemStore.put(metaNamespaceKeyFunc(cm), cm); + + assertThat(transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getName()) + .isNotNull(); + assertThat(transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getNamespace()) + .isNotNull(); + assertThat( + transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getResourceVersion()) + .isNotNull(); + } + + ConfigMap configMap() { + var cm = new ConfigMap(); + cm.setMetadata(new ObjectMetaBuilder() + .withName("test1") + .withNamespace("default").withResourceVersion("1") + .build()); + return cm; + } + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CachePruneIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CachePruneIT.java new file mode 100644 index 0000000000..9bf57ab372 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CachePruneIT.java @@ -0,0 +1,77 @@ +package io.javaoperatorsdk.operator; + +import java.time.Duration; +import java.util.Map; + +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.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.sample.cacheprune.CachePruneCustomResource; +import io.javaoperatorsdk.operator.sample.cacheprune.CachePruneReconciler; +import io.javaoperatorsdk.operator.sample.cacheprune.CachePruneSpec; + +import static io.javaoperatorsdk.operator.sample.cacheprune.CachePruneReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CachePruneIT { + + public static final String DEFAULT_DATA = "default_data"; + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String UPDATED_DATA = "updated_data"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new CachePruneReconciler()).build(); + + @Test + void pruningRelatedBehavior() { + var res = operator.create(testResource()); + await().untilAsserted(() -> { + assertState(DEFAULT_DATA); + }); + + res.getSpec().setData(UPDATED_DATA); + var updated = operator.replace(res); + + await().untilAsserted(() -> { + assertState(UPDATED_DATA); + }); + + operator.delete(updated); + + await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> { + var actual = operator.get(CachePruneCustomResource.class, TEST_RESOURCE_NAME); + var configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(configMap).isNull(); + assertThat(actual).isNull(); + }); + } + + void assertState(String expectedData) { + var actual = operator.get(CachePruneCustomResource.class, TEST_RESOURCE_NAME); + assertThat(actual.getMetadata()).isNotNull(); + assertThat(actual.getMetadata().getFinalizers()).isNotEmpty(); + assertThat(actual.getStatus().getCreated()).isTrue(); + assertThat(actual.getMetadata().getLabels()).isNotEmpty(); + var configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(configMap.getData()).containsEntry(DATA_KEY, expectedData); + assertThat(configMap.getMetadata().getLabels()).isNotEmpty(); + } + + CachePruneCustomResource testResource() { + var res = new CachePruneCustomResource(); + res.setMetadata(new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withLabels(Map.of("sampleLabel", "val")) + .build()); + res.setSpec(new CachePruneSpec()); + res.getSpec().setData(DEFAULT_DATA); + return res; + } + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneCustomResource.java new file mode 100644 index 0000000000..60431588fd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.sample.cacheprune; + +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("cpr") +public class CachePruneCustomResource + extends CustomResource + implements Namespaced { +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneReconciler.java new file mode 100644 index 0000000000..236b205c2c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneReconciler.java @@ -0,0 +1,107 @@ +package io.javaoperatorsdk.operator.sample.cacheprune; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceProvider; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.junit.KubernetesClientAware; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration(cachePruneFunction = LabelRemovingPruneFunction.class) +public class CachePruneReconciler + implements Reconciler, + EventSourceInitializer, + Cleaner, KubernetesClientAware { + + public static final String DATA_KEY = "data"; + public static final String FIELD_MANAGER = "controller"; + public static final String SECONDARY_CREATE_FIELD_MANAGER = "creator"; + private KubernetesClient client; + + @Override + public UpdateControl reconcile( + CachePruneCustomResource resource, + Context context) { + var configMap = context.getSecondaryResource(ConfigMap.class); + configMap.ifPresentOrElse(cm -> { + if (!cm.getMetadata().getLabels().isEmpty()) { + throw new AssertionError("Labels should be null"); + } + if (!cm.getData().get(DATA_KEY) + .equals(resource.getSpec().getData())) { + var cloned = ConfigurationServiceProvider.instance().getResourceCloner().clone(cm); + cloned.getData().put(DATA_KEY, resource.getSpec().getData()); + var patchContext = patchContextWithFieldManager(FIELD_MANAGER); + // setting new field manager since we don't control label anymore: + // since not the whole object is present in cache SSA would remove labels if the controller + // is not the manager. + // Note that JSON Merge Patch (or others would also work here, without this "hack". + patchContext.setForce(true); + patchContext.setFieldManager(FIELD_MANAGER); + client.configMaps().resource(cm) + .patch(patchContext, cloned); + } + }, () -> client.configMaps().resource(configMap(resource)) + .patch(patchContextWithFieldManager(SECONDARY_CREATE_FIELD_MANAGER))); + + resource.setStatus(new CachePruneStatus()); + resource.getStatus().setCreated(true); + return UpdateControl.patchStatus(resource); + } + + private PatchContext patchContextWithFieldManager(String fieldManager) { + PatchContext patchContext = new PatchContext(); + // using server side apply + patchContext.setPatchType(PatchType.SERVER_SIDE_APPLY); + patchContext.setFieldManager(fieldManager); + return patchContext; + } + + @Override + public Map prepareEventSources( + EventSourceContext context) { + InformerEventSource configMapEventSource = + new InformerEventSource<>(InformerConfiguration.from(ConfigMap.class, context) + .withCachePruneFunction(new LabelRemovingPruneFunction<>()) + .build(), + context); + return EventSourceInitializer.nameEventSources(configMapEventSource); + } + + ConfigMap configMap(CachePruneCustomResource resource) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(resource.getMetadata().getName()); + configMap.getMetadata().setNamespace(resource.getMetadata().getNamespace()); + configMap.setData(Map.of(DATA_KEY, resource.getSpec().getData())); + HashMap labels = new HashMap<>(); + labels.put("mylabel", "val"); + configMap.getMetadata().setLabels(labels); + configMap.addOwnerReference(resource); + return configMap; + } + + @Override + public KubernetesClient getKubernetesClient() { + return client; + } + + @Override + public void setKubernetesClient(KubernetesClient kubernetesClient) { + this.client = kubernetesClient; + } + + @Override + public DeleteControl cleanup(CachePruneCustomResource resource, + Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneSpec.java new file mode 100644 index 0000000000..2d58a70d3a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.sample.cacheprune; + +public class CachePruneSpec { + + private String data; + + public String getData() { + return data; + } + + public CachePruneSpec setData(String data) { + this.data = data; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneStatus.java new file mode 100644 index 0000000000..a074c0e011 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/CachePruneStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.sample.cacheprune; + +public class CachePruneStatus { + + private Boolean created; + + public Boolean getCreated() { + return created; + } + + public CachePruneStatus setCreated(Boolean created) { + this.created = created; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/LabelRemovingPruneFunction.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/LabelRemovingPruneFunction.java new file mode 100644 index 0000000000..a495803628 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/cacheprune/LabelRemovingPruneFunction.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.sample.cacheprune; + +import java.util.function.UnaryOperator; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public class LabelRemovingPruneFunction implements UnaryOperator { + @Override + public R apply(R r) { + r.getMetadata().setLabels(null); + return r; + } +} diff --git a/sample-operators/webpage/src/main/resources/log4j2.xml b/sample-operators/webpage/src/main/resources/log4j2.xml index 5b794e7de3..3e92919d3b 100644 --- a/sample-operators/webpage/src/main/resources/log4j2.xml +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -2,7 +2,7 @@ - +