From 6b3cc1d6ccc7c3f09bc7fb5681c6669e527010a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 11:00:35 +0100 Subject: [PATCH 01/11] Kubernetes Client Facade instead of Reconcile Utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Context.java | 2 + .../api/reconciler/DefaultContext.java | 7 + .../reconciler/KubernetesClientFacade.java | 545 ++++++++++++++++ .../api/reconciler/ReconcileUtils.java | 572 ----------------- .../event/ReconciliationDispatcher.java | 28 +- .../api/reconciler/ReconcileUtilsTest.java | 590 +++++++++--------- 6 files changed, 853 insertions(+), 891 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/KubernetesClientFacade.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java index cc7c865dc5..b10883c6b2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -58,6 +58,8 @@ default Stream getSecondaryResourcesAsStream(Class expectedType) { KubernetesClient getClient(); + KubernetesClientFacade

getClientFacade(); + /** ExecutorService initialized by framework for workflows. Used for workflow standalone mode. */ ExecutorService getWorkflowExecutorService(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java index f1aeadd52a..7ac161e2d3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -46,6 +46,7 @@ public class DefaultContext

implements Context

{ private final boolean primaryResourceDeleted; private final boolean primaryResourceFinalStateUnknown; private final Map, Object> desiredStates = new ConcurrentHashMap<>(); + private final KubernetesClientFacade

clientFacade; public DefaultContext( RetryInfo retryInfo, @@ -61,6 +62,7 @@ public DefaultContext( this.primaryResourceFinalStateUnknown = primaryResourceFinalStateUnknown; this.defaultManagedDependentResourceContext = new DefaultManagedWorkflowAndDependentResourceContext<>(controller, primaryResource, this); + this.clientFacade = new KubernetesClientFacade<>(this); } @Override @@ -124,6 +126,11 @@ public KubernetesClient getClient() { return controller.getClient(); } + @Override + public KubernetesClientFacade

getClientFacade() { + return null; + } + @Override public ExecutorService getWorkflowExecutorService() { // note that this should be always received from executor service manager, so we are able to do diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/KubernetesClientFacade.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/KubernetesClientFacade.java new file mode 100644 index 0000000000..348963ca16 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/KubernetesClientFacade.java @@ -0,0 +1,545 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.lang.reflect.InvocationTargetException; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; + +import org.slf4j.Logger; +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.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; + +import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; +import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; + +public class KubernetesClientFacade

{ + + public static final int DEFAULT_MAX_RETRY = 10; + + private static final Logger log = LoggerFactory.getLogger(KubernetesClientFacade.class); + + private final Context

context; + + public KubernetesClientFacade(Context

context) { + this.context = context; + } + + /** + * Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will contain to updated resource. Or more recent one if someone did an update + * after our update. + * + *

Optionally also can filter out the event, what is the result of this update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. In case of SSA we advise not to do updates with optimistic locking. + * + * @param resource fresh resource for server side apply + * @return updated resource + * @param resource type + */ + public R serverSideApply(R resource) { + return resourcePatch( + context, + resource, + r -> + context + .getClient() + .resource(r) + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build())); + } + + /** + * Server-Side Apply the resource status subresource. Updates the resource status and caches the + * response if needed, ensuring the next reconciliation will contain the updated resource. + * + * @param resource fresh resource for server side apply + * @return updated resource + * @param resource type + */ + public R serverSideApplyStatus(R resource) { + return resourcePatch( + context, + resource, + r -> + context + .getClient() + .resource(r) + .subresource("status") + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build())); + } + + /** + * Server-Side Apply the primary resource. Updates the primary resource and caches the response + * using the controller's event source, ensuring the next reconciliation will contain the updated + * resource. + * + * @param resource primary resource for server side apply + * @return updated resource + * @param

primary resource type + */ + public

P serverSideApplyPrimary(P resource) { + return resourcePatch( + resource, + r -> + context + .getClient() + .resource(r) + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Server-Side Apply the primary resource status subresource. Updates the primary resource status + * and caches the response using the controller's event source. + * + * @param resource primary resource for server side apply + * @return updated resource + * @param

primary resource type + */ + public

P serverSideApplyPrimaryStatus(P resource) { + return resourcePatch( + resource, + r -> + context + .getClient() + .resource(r) + .subresource("status") + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Updates the resource with optimistic locking based on the resource version. Caches the response + * if needed, ensuring the next reconciliation will contain the updated resource. + * + * @param resource resource to update + * @return updated resource + * @param resource type + */ + public R update(R resource) { + return resourcePatch(context, resource, r -> context.getClient().resource(r).update()); + } + + /** + * Updates the resource status subresource with optimistic locking. Caches the response if needed. + * + * @param resource resource to update + * @return updated resource + * @param resource type + */ + public R updateStatus(R resource) { + return resourcePatch(context, resource, r -> context.getClient().resource(r).updateStatus()); + } + + /** + * Updates the primary resource with optimistic locking. Caches the response using the + * controller's event source. + * + * @param resource primary resource to update + * @return updated resource + * @param resource type + */ + public R updatePrimary(R resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).update(), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Updates the primary resource status subresource with optimistic locking. Caches the response + * using the controller's event source. + * + * @param resource primary resource to update + * @return updated resource + * @param resource type + */ + public R updatePrimaryStatus(R resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).updateStatus(), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Applies a JSON Patch to the resource. The unaryOperator function is used to modify the + * resource, and the differences are sent as a JSON Patch to the Kubernetes API server. + * + * @param resource resource to patch + * @param unaryOperator function to modify the resource + * @return updated resource + * @param resource type + */ + public R jsonPatch(R resource, UnaryOperator unaryOperator) { + return resourcePatch( + context, resource, r -> context.getClient().resource(r).edit(unaryOperator)); + } + + /** + * Applies a JSON Patch to the resource status subresource. The unaryOperator function is used to + * modify the resource status, and the differences are sent as a JSON Patch. + * + * @param resource resource to patch + * @param unaryOperator function to modify the resource + * @return updated resource + * @param resource type + */ + public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { + return resourcePatch( + context, resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); + } + + /** + * Applies a JSON Patch to the primary resource. Caches the response using the controller's event + * source. + * + * @param resource primary resource to patch + * @param unaryOperator function to modify the resource + * @return updated resource + * @param resource type + */ + public R jsonPatchPrimary(R resource, UnaryOperator unaryOperator) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).edit(unaryOperator), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Applies a JSON Patch to the primary resource status subresource. Caches the response using the + * controller's event source. + * + * @param resource primary resource to patch + * @param unaryOperator function to modify the resource + * @return updated resource + * @param resource type + */ + public R jsonPatchPrimaryStatus( + R resource, UnaryOperator unaryOperator) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).editStatus(unaryOperator), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Applies a JSON Merge Patch to the resource. JSON Merge Patch (RFC 7386) is a simpler patching + * strategy that merges the provided resource with the existing resource on the server. + * + * @param resource resource to patch + * @return updated resource + * @param resource type + */ + public R jsonMergePatch(R resource) { + return resourcePatch(context, resource, r -> context.getClient().resource(r).patch()); + } + + /** + * Applies a JSON Merge Patch to the resource status subresource. Merges the provided resource + * status with the existing resource status on the server. + * + * @param resource resource to patch + * @return updated resource + * @param resource type + */ + public R jsonMergePatchStatus(R resource) { + return resourcePatch(context, resource, r -> context.getClient().resource(r).patchStatus()); + } + + /** + * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's + * event source. + * + * @param resource primary resource to patch reconciliation + * @return updated resource + * @param resource type + */ + public R jsonMergePatchPrimary(R resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).patch(), + context.eventSourceRetriever().getControllerEventSource()); + } + + /** + * Applies a JSON Merge Patch to the primary resource status subresource and filters out the + * resulting event. This is a convenience method that calls {@link + * #jsonMergePatchPrimaryStatus(HasMetadata)} with filterEvent set to true. + * + * @param resource primary resource to patch + * @return updated resource + * @param resource type + * @see #jsonMergePatchPrimaryStatus(HasMetadata) + */ + public R jsonMergePatchPrimaryStatus(R resource) { + return jsonMergePatchPrimaryStatus(resource); + } + + /** + * Internal utility method to patch a resource and cache the result. Automatically discovers the + * event source for the resource type and delegates to {@link #resourcePatch(HasMetadata, + * UnaryOperator, ManagedInformerEventSource)}. + * + * @param context of reconciler + * @param resource resource to patch + * @param updateOperation operation to perform (update, patch, edit, etc.) + * @return updated resource + * @param resource type + * @throws IllegalStateException if no event source or multiple event sources are found + */ + public R resourcePatch( + Context context, R resource, UnaryOperator updateOperation) { + + var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); + if (esList.isEmpty()) { + throw new IllegalStateException("No event source found for type: " + resource.getClass()); + } + if (esList.size() > 1) { + throw new IllegalStateException( + "Multiple event sources found for: " + + resource.getClass() + + " please provide the target event source"); + } + var es = esList.get(0); + if (es instanceof ManagedInformerEventSource mes) { + return resourcePatch(resource, updateOperation, mes); + } else { + throw new IllegalStateException( + "Target event source must be a subclass off " + + ManagedInformerEventSource.class.getName()); + } + } + + /** + * Internal utility method to patch a resource and cache the result using the specified event + * source. This method either filters out the resulting event or allows it to trigger + * reconciliation based on the filterEvent parameter. + * + * @param resource resource to patch + * @param updateOperation operation to perform (update, patch, edit, etc.) + * @param ies the managed informer event source to use for caching + * @return updated resource + * @param resource type + */ + @SuppressWarnings("unchecked") + public R resourcePatch( + R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { + return (R) ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); + } + + /** + * Adds the default finalizer (from controller configuration) to the primary resource. This is a + * convenience method that calls {@link #addFinalizer(String)} with the configured finalizer name. + * + * @return updated resource from the server response + * @see #addFinalizer(String) + */ + public P addFinalizer() { + return addFinalizer(context.getControllerConfiguration().getFinalizerName()); + } + + /** + * Adds finalizer to the resource using JSON Patch. Retries conflicts and unprocessable content + * (HTTP 422), see {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, + * HasMetadata, UnaryOperator, Predicate)} for details on retry. It does not try to add finalizer + * if there is already a finalizer or resource is marked for deletion. + * + * @return updated resource from the server response + */ + public P addFinalizer(String finalizerName) { + var resource = context.getPrimaryResource(); + if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) { + return resource; + } + return conflictRetryingPatch( + r -> { + r.addFinalizer(finalizerName); + return r; + }, + r -> !r.hasFinalizer(finalizerName)); + } + + /** + * Removes the default finalizer (from controller configuration) from the primary resource. This + * is a convenience method that calls {@link #removeFinalizer(String)} with the configured + * finalizer name. + * + * @return updated resource from the server response + * @see #removeFinalizer(String) + */ + public P removeFinalizer() { + return removeFinalizer(context.getControllerConfiguration().getFinalizerName()); + } + + /** + * Removes the target finalizer from target resource. Uses JSON Patch and handles retries, see + * {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, HasMetadata, + * UnaryOperator, Predicate)} for details. It does not try to remove finalizer if finalizer is not + * present on the resource. + * + * @return updated resource from the server response + */ + public P removeFinalizer(String finalizerName) { + var resource = context.getPrimaryResource(); + if (!resource.hasFinalizer(finalizerName)) { + return resource; + } + return conflictRetryingPatch( + r -> { + r.removeFinalizer(finalizerName); + return r; + }, + r -> { + if (r == null) { + log.warn("Cannot remove finalizer since resource not exists."); + return false; + } + return r.hasFinalizer(finalizerName); + }); + } + + /** + * Patches the resource using JSON Patch. In case the server responds with conflict (HTTP 409) or + * unprocessable content (HTTP 422) it retries the operation up to the maximum number defined in + * {@link KubernetesClientFacade#DEFAULT_MAX_RETRY}. + * + * @param resourceChangesOperator changes to be done on the resource before update + * @param preCondition condition to check if the patch operation still needs to be performed or + * not. + * @return updated resource from the server or unchanged if the precondition does not hold. + * @param resource type + */ + @SuppressWarnings("unchecked") + public R conflictRetryingPatch( + UnaryOperator resourceChangesOperator, Predicate preCondition) { + var resource = context.getPrimaryResource(); + var client = context.getClient(); + if (log.isDebugEnabled()) { + log.debug("Conflict retrying update for: {}", ResourceID.fromResource(resource)); + } + int retryIndex = 0; + while (true) { + try { + if (!preCondition.test((R) resource)) { + return (R) resource; + } + return jsonPatchPrimary((R) resource, resourceChangesOperator); + } catch (KubernetesClientException e) { + log.trace("Exception during patch for resource: {}", resource); + retryIndex++; + // only retry on conflict (409) and unprocessable content (422) which + // can happen if JSON Patch is not a valid request since there was + // a concurrent request which already removed another finalizer: + // List element removal from a list is by index in JSON Patch + // so if addressing a second finalizer but first is meanwhile removed + // it is a wrong request. + if (e.getCode() != 409 && e.getCode() != 422) { + throw e; + } + if (retryIndex >= DEFAULT_MAX_RETRY) { + throw new OperatorException( + "Exceeded maximum (" + + DEFAULT_MAX_RETRY + + ") retry attempts to patch resource: " + + ResourceID.fromResource(resource)); + } + log.debug( + "Retrying patch for resource name: {}, namespace: {}; HTTP code: {}", + resource.getMetadata().getName(), + resource.getMetadata().getNamespace(), + e.getCode()); + var operation = client.resources(resource.getClass()); + if (resource.getMetadata().getNamespace() != null) { + resource = + (P) + operation + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getMetadata().getName()) + .get(); + } else { + resource = (P) operation.withName(resource.getMetadata().getName()).get(); + } + } + } + } + + /** + * Adds the default finalizer (from controller configuration) to the primary resource using + * Server-Side Apply. This is a convenience method that calls {@link #addFinalizerWithSSA( + * String)} with the configured finalizer name. + * + * @return the patched resource from the server response + * @param

primary resource type + * @see #addFinalizerWithSSA(String) + */ + public

P addFinalizerWithSSA() { + return addFinalizerWithSSA(context.getControllerConfiguration().getFinalizerName()); + } + + /** + * Adds finalizer using Server-Side Apply. In the background this method creates a fresh copy of + * the target resource, setting only name, namespace and finalizer. Does not use optimistic + * locking for the patch. + * + * @param finalizerName name of the finalizer to add + * @return the patched resource from the server response + * @param

primary resource type + */ + public

P addFinalizerWithSSA(String finalizerName) { + var originalResource = context.getPrimaryResource(); + if (log.isDebugEnabled()) { + log.debug( + "Adding finalizer (using SSA) for resource: {} version: {}", + getUID(originalResource), + getVersion(originalResource)); + } + 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.addFinalizer(finalizerName); + + return serverSideApplyPrimary(resource); + } catch (InstantiationException + | IllegalAccessException + | InvocationTargetException + | NoSuchMethodException e) { + throw new RuntimeException( + "Issue with creating custom resource instance with reflection." + + " Custom Resources must provide a no-arg constructor. Class: " + + originalResource.getClass().getName(), + e); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java index ed02c56a01..a5ae57a8bf 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java @@ -15,584 +15,12 @@ */ package io.javaoperatorsdk.operator.api.reconciler; -import java.lang.reflect.InvocationTargetException; -import java.util.function.Predicate; -import java.util.function.UnaryOperator; - import org.slf4j.Logger; 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.processing.event.ResourceID; -import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; - -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; - public class ReconcileUtils { private static final Logger log = LoggerFactory.getLogger(ReconcileUtils.class); - public static final int DEFAULT_MAX_RETRY = 10; - private ReconcileUtils() {} - - /** - * Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will contain to updated resource. Or more recent one if someone did an update - * after our update. - * - *

Optionally also can filter out the event, what is the result of this update. - * - *

You are free to control the optimistic locking by setting the resource version in resource - * metadata. In case of SSA we advise not to do updates with optimistic locking. - * - * @param context of reconciler - * @param resource fresh resource for server side apply - * @return updated resource - * @param resource type - */ - public static R serverSideApply( - Context context, R resource) { - return resourcePatch( - context, - resource, - r -> - context - .getClient() - .resource(r) - .patch( - new PatchContext.Builder() - .withForce(true) - .withFieldManager(context.getControllerConfiguration().fieldManager()) - .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build())); - } - - /** - * Server-Side Apply the resource status subresource. Updates the resource status and caches the - * response if needed, ensuring the next reconciliation will contain the updated resource. - * - * @param context of reconciler - * @param resource fresh resource for server side apply - * @return updated resource - * @param resource type - */ - public static R serverSideApplyStatus( - Context context, R resource) { - return resourcePatch( - context, - resource, - r -> - context - .getClient() - .resource(r) - .subresource("status") - .patch( - new PatchContext.Builder() - .withForce(true) - .withFieldManager(context.getControllerConfiguration().fieldManager()) - .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build())); - } - - /** - * Server-Side Apply the primary resource. Updates the primary resource and caches the response - * using the controller's event source, ensuring the next reconciliation will contain the updated - * resource. - * - * @param context of reconciler - * @param resource primary resource for server side apply - * @return updated resource - * @param

primary resource type - */ - public static

P serverSideApplyPrimary(Context

context, P resource) { - return resourcePatch( - resource, - r -> - context - .getClient() - .resource(r) - .patch( - new PatchContext.Builder() - .withForce(true) - .withFieldManager(context.getControllerConfiguration().fieldManager()) - .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build()), - context.eventSourceRetriever().getControllerEventSource()); - } - - /** - * Server-Side Apply the primary resource status subresource. Updates the primary resource status - * and caches the response using the controller's event source. - * - * @param context of reconciler - * @param resource primary resource for server side apply - * @return updated resource - * @param

primary resource type - */ - public static

P serverSideApplyPrimaryStatus( - Context

context, P resource) { - return resourcePatch( - resource, - r -> - context - .getClient() - .resource(r) - .subresource("status") - .patch( - new PatchContext.Builder() - .withForce(true) - .withFieldManager(context.getControllerConfiguration().fieldManager()) - .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build()), - context.eventSourceRetriever().getControllerEventSource()); - } - - /** - * Updates the resource with optimistic locking based on the resource version. Caches the response - * if needed, ensuring the next reconciliation will contain the updated resource. - * - * @param context of reconciler - * @param resource resource to update - * @return updated resource - * @param resource type - */ - public static R update( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).update()); - } - - /** - * Updates the resource status subresource with optimistic locking. Caches the response if needed. - * - * @param context of reconciler - * @param resource resource to update - * @return updated resource - * @param resource type - */ - public static R updateStatus( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).updateStatus()); - } - - /** - * Updates the primary resource with optimistic locking. Caches the response using the - * controller's event source. - * - * @param context of reconciler - * @param resource primary resource to update - * @return updated resource - * @param resource type - */ - public static R updatePrimary( - Context context, R resource) { - return resourcePatch( - resource, - r -> context.getClient().resource(r).update(), - context.eventSourceRetriever().getControllerEventSource()); - } - - /** - * Updates the primary resource status subresource with optimistic locking. Caches the response - * using the controller's event source. - * - * @param context of reconciler - * @param resource primary resource to update - * @return updated resource - * @param resource type - */ - public static R updatePrimaryStatus( - Context context, R resource) { - return resourcePatch( - resource, - r -> context.getClient().resource(r).updateStatus(), - context.eventSourceRetriever().getControllerEventSource()); - } - - /** - * Applies a JSON Patch to the resource. The unaryOperator function is used to modify the - * resource, and the differences are sent as a JSON Patch to the Kubernetes API server. - * - * @param context of reconciler - * @param resource resource to patch - * @param unaryOperator function to modify the resource - * @return updated resource - * @param resource type - */ - public static R jsonPatch( - Context context, R resource, UnaryOperator unaryOperator) { - return resourcePatch( - context, resource, r -> context.getClient().resource(r).edit(unaryOperator)); - } - - /** - * Applies a JSON Patch to the resource status subresource. The unaryOperator function is used to - * modify the resource status, and the differences are sent as a JSON Patch. - * - * @param context of reconciler - * @param resource resource to patch - * @param unaryOperator function to modify the resource - * @return updated resource - * @param resource type - */ - public static R jsonPatchStatus( - Context context, R resource, UnaryOperator unaryOperator) { - return resourcePatch( - context, resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); - } - - /** - * Applies a JSON Patch to the primary resource. Caches the response using the controller's event - * source. - * - * @param context of reconciler - * @param resource primary resource to patch - * @param unaryOperator function to modify the resource - * @return updated resource - * @param resource type - */ - public static R jsonPatchPrimary( - Context context, R resource, UnaryOperator unaryOperator) { - return resourcePatch( - resource, - r -> context.getClient().resource(r).edit(unaryOperator), - context.eventSourceRetriever().getControllerEventSource()); - } - - /** - * Applies a JSON Patch to the primary resource status subresource. Caches the response using the - * controller's event source. - * - * @param context of reconciler - * @param resource primary resource to patch - * @param unaryOperator function to modify the resource - * @return updated resource - * @param resource type - */ - public static R jsonPatchPrimaryStatus( - Context context, R resource, UnaryOperator unaryOperator) { - return resourcePatch( - resource, - r -> context.getClient().resource(r).editStatus(unaryOperator), - context.eventSourceRetriever().getControllerEventSource()); - } - - /** - * Applies a JSON Merge Patch to the resource. JSON Merge Patch (RFC 7386) is a simpler patching - * strategy that merges the provided resource with the existing resource on the server. - * - * @param context of reconciler - * @param resource resource to patch - * @return updated resource - * @param resource type - */ - public static R jsonMergePatch( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).patch()); - } - - /** - * Applies a JSON Merge Patch to the resource status subresource. Merges the provided resource - * status with the existing resource status on the server. - * - * @param context of reconciler - * @param resource resource to patch - * @return updated resource - * @param resource type - */ - public static R jsonMergePatchStatus( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).patchStatus()); - } - - /** - * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's - * event source. - * - * @param context of reconciler - * @param resource primary resource to patch reconciliation - * @return updated resource - * @param resource type - */ - public static R jsonMergePatchPrimary( - Context context, R resource) { - return resourcePatch( - resource, - r -> context.getClient().resource(r).patch(), - context.eventSourceRetriever().getControllerEventSource()); - } - - /** - * Applies a JSON Merge Patch to the primary resource status subresource and filters out the - * resulting event. This is a convenience method that calls {@link - * #jsonMergePatchPrimaryStatus(Context, HasMetadata)} with filterEvent set to true. - * - * @param context of reconciler - * @param resource primary resource to patch - * @return updated resource - * @param resource type - * @see #jsonMergePatchPrimaryStatus(Context, HasMetadata) - */ - public static R jsonMergePatchPrimaryStatus( - Context context, R resource) { - return jsonMergePatchPrimaryStatus(context, resource); - } - - /** - * Internal utility method to patch a resource and cache the result. Automatically discovers the - * event source for the resource type and delegates to {@link #resourcePatch(HasMetadata, - * UnaryOperator, ManagedInformerEventSource)}. - * - * @param context of reconciler - * @param resource resource to patch - * @param updateOperation operation to perform (update, patch, edit, etc.) - * @return updated resource - * @param resource type - * @throws IllegalStateException if no event source or multiple event sources are found - */ - public static R resourcePatch( - Context context, R resource, UnaryOperator updateOperation) { - - var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); - if (esList.isEmpty()) { - throw new IllegalStateException("No event source found for type: " + resource.getClass()); - } - if (esList.size() > 1) { - throw new IllegalStateException( - "Multiple event sources found for: " - + resource.getClass() - + " please provide the target event source"); - } - var es = esList.get(0); - if (es instanceof ManagedInformerEventSource mes) { - return resourcePatch(resource, updateOperation, mes); - } else { - throw new IllegalStateException( - "Target event source must be a subclass off " - + ManagedInformerEventSource.class.getName()); - } - } - - /** - * Internal utility method to patch a resource and cache the result using the specified event - * source. This method either filters out the resulting event or allows it to trigger - * reconciliation based on the filterEvent parameter. - * - * @param resource resource to patch - * @param updateOperation operation to perform (update, patch, edit, etc.) - * @param ies the managed informer event source to use for caching - * @return updated resource - * @param resource type - */ - @SuppressWarnings("unchecked") - public static R resourcePatch( - R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { - return (R) ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); - } - - /** - * Adds the default finalizer (from controller configuration) to the primary resource. This is a - * convenience method that calls {@link #addFinalizer(Context, String)} with the configured - * finalizer name. - * - * @param context of reconciler - * @return updated resource from the server response - * @param

primary resource type - * @see #addFinalizer(Context, String) - */ - public static

P addFinalizer(Context

context) { - return addFinalizer(context, context.getControllerConfiguration().getFinalizerName()); - } - - /** - * Adds finalizer to the resource using JSON Patch. Retries conflicts and unprocessable content - * (HTTP 422), see {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, - * HasMetadata, UnaryOperator, Predicate)} for details on retry. It does not try to add finalizer - * if there is already a finalizer or resource is marked for deletion. - * - * @return updated resource from the server response - */ - public static

P addFinalizer(Context

context, String finalizerName) { - var resource = context.getPrimaryResource(); - if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) { - return resource; - } - return conflictRetryingPatch( - context, - r -> { - r.addFinalizer(finalizerName); - return r; - }, - r -> !r.hasFinalizer(finalizerName)); - } - - /** - * Removes the default finalizer (from controller configuration) from the primary resource. This - * is a convenience method that calls {@link #removeFinalizer(Context, String)} with the - * configured finalizer name. - * - * @param context of reconciler - * @return updated resource from the server response - * @param

primary resource type - * @see #removeFinalizer(Context, String) - */ - public static

P removeFinalizer(Context

context) { - return removeFinalizer(context, context.getControllerConfiguration().getFinalizerName()); - } - - /** - * Removes the target finalizer from target resource. Uses JSON Patch and handles retries, see - * {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, HasMetadata, - * UnaryOperator, Predicate)} for details. It does not try to remove finalizer if finalizer is not - * present on the resource. - * - * @return updated resource from the server response - */ - public static

P removeFinalizer( - Context

context, String finalizerName) { - var resource = context.getPrimaryResource(); - if (!resource.hasFinalizer(finalizerName)) { - return resource; - } - return conflictRetryingPatch( - context, - r -> { - r.removeFinalizer(finalizerName); - return r; - }, - r -> { - if (r == null) { - log.warn("Cannot remove finalizer since resource not exists."); - return false; - } - return r.hasFinalizer(finalizerName); - }); - } - - /** - * Patches the resource using JSON Patch. In case the server responds with conflict (HTTP 409) or - * unprocessable content (HTTP 422) it retries the operation up to the maximum number defined in - * {@link ReconcileUtils#DEFAULT_MAX_RETRY}. - * - * @param context reconciliation context - * @param resourceChangesOperator changes to be done on the resource before update - * @param preCondition condition to check if the patch operation still needs to be performed or - * not. - * @return updated resource from the server or unchanged if the precondition does not hold. - * @param

resource type - */ - @SuppressWarnings("unchecked") - public static

P conflictRetryingPatch( - Context

context, UnaryOperator

resourceChangesOperator, Predicate

preCondition) { - var resource = context.getPrimaryResource(); - var client = context.getClient(); - if (log.isDebugEnabled()) { - log.debug("Conflict retrying update for: {}", ResourceID.fromResource(resource)); - } - int retryIndex = 0; - while (true) { - try { - if (!preCondition.test(resource)) { - return resource; - } - return jsonPatchPrimary(context, resource, resourceChangesOperator); - } catch (KubernetesClientException e) { - log.trace("Exception during patch for resource: {}", resource); - retryIndex++; - // only retry on conflict (409) and unprocessable content (422) which - // can happen if JSON Patch is not a valid request since there was - // a concurrent request which already removed another finalizer: - // List element removal from a list is by index in JSON Patch - // so if addressing a second finalizer but first is meanwhile removed - // it is a wrong request. - if (e.getCode() != 409 && e.getCode() != 422) { - throw e; - } - if (retryIndex >= DEFAULT_MAX_RETRY) { - throw new OperatorException( - "Exceeded maximum (" - + DEFAULT_MAX_RETRY - + ") retry attempts to patch resource: " - + ResourceID.fromResource(resource)); - } - log.debug( - "Retrying patch for resource name: {}, namespace: {}; HTTP code: {}", - resource.getMetadata().getName(), - resource.getMetadata().getNamespace(), - e.getCode()); - var operation = client.resources(resource.getClass()); - if (resource.getMetadata().getNamespace() != null) { - resource = - (P) - operation - .inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getMetadata().getName()) - .get(); - } else { - resource = (P) operation.withName(resource.getMetadata().getName()).get(); - } - } - } - } - - /** - * Adds the default finalizer (from controller configuration) to the primary resource using - * Server-Side Apply. This is a convenience method that calls {@link #addFinalizerWithSSA(Context, - * String)} with the configured finalizer name. - * - * @param context of reconciler - * @return the patched resource from the server response - * @param

primary resource type - * @see #addFinalizerWithSSA(Context, String) - */ - public static

P addFinalizerWithSSA(Context

context) { - return addFinalizerWithSSA(context, context.getControllerConfiguration().getFinalizerName()); - } - - /** - * Adds finalizer using Server-Side Apply. In the background this method creates a fresh copy of - * the target resource, setting only name, namespace and finalizer. Does not use optimistic - * locking for the patch. - * - * @param context of reconciler - * @param finalizerName name of the finalizer to add - * @return the patched resource from the server response - * @param

primary resource type - */ - public static

P addFinalizerWithSSA( - Context

context, String finalizerName) { - var originalResource = context.getPrimaryResource(); - if (log.isDebugEnabled()) { - log.debug( - "Adding finalizer (using SSA) for resource: {} version: {}", - getUID(originalResource), - getVersion(originalResource)); - } - 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.addFinalizer(finalizerName); - - return serverSideApplyPrimary(context, resource); - } catch (InstantiationException - | IllegalAccessException - | InvocationTargetException - | NoSuchMethodException e) { - throw new RuntimeException( - "Issue with creating custom resource instance with reflection." - + " Custom Resources must provide a no-arg constructor. Class: " - + originalResource.getClass().getName(), - e); - } - } } 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 82d9a3ed21..4d7a59e5c0 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 @@ -31,7 +31,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.Controller; @@ -137,9 +136,9 @@ private PostExecutionControl

handleReconcile( */ P updatedResource; if (useSSA) { - updatedResource = ReconcileUtils.addFinalizerWithSSA(context); + updatedResource = context.getClientFacade().addFinalizerWithSSA(); } else { - updatedResource = ReconcileUtils.addFinalizer(context); + updatedResource = context.getClientFacade().addFinalizer(); } return PostExecutionControl.onlyFinalizerAdded(updatedResource) .withReSchedule(BaseControl.INSTANT_RESCHEDULE); @@ -321,7 +320,7 @@ private PostExecutionControl

handleCleanup( // cleanup is finished, nothing left to be done final var finalizerName = configuration().getFinalizerName(); if (deleteControl.isRemoveFinalizer() && resourceForExecution.hasFinalizer(finalizerName)) { - P customResource = ReconcileUtils.removeFinalizer(context); + P customResource = context.getClientFacade().removeFinalizer(); return PostExecutionControl.customResourceFinalizerRemoved(customResource); } } @@ -387,9 +386,9 @@ public R patchResource(Context context, R resource, R originalResource) { resource.getMetadata().getResourceVersion()); } if (useSSA) { - return ReconcileUtils.serverSideApplyPrimary(context, resource); + return context.getClientFacade().serverSideApplyPrimary(resource); } else { - return ReconcileUtils.jsonPatchPrimary(context, originalResource, r -> resource); + return context.getClientFacade().jsonPatchPrimary(originalResource, r -> resource); } } @@ -399,7 +398,7 @@ public R patchStatus(Context context, R resource, R originalResource) { var managedFields = resource.getMetadata().getManagedFields(); try { resource.getMetadata().setManagedFields(null); - return ReconcileUtils.serverSideApplyPrimaryStatus(context, resource); + return context.getClientFacade().serverSideApplyPrimaryStatus(resource); } finally { resource.getMetadata().setManagedFields(managedFields); } @@ -416,13 +415,14 @@ private R editStatus(Context context, R resource, R originalResource) { try { clonedOriginal.getMetadata().setResourceVersion(null); resource.getMetadata().setResourceVersion(null); - return ReconcileUtils.jsonPatchPrimaryStatus( - context, - clonedOriginal, - r -> { - ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); - return r; - }); + return context + .getClientFacade() + .jsonPatchPrimaryStatus( + clonedOriginal, + r -> { + ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); + return r; + }); } finally { // restore initial resource version clonedOriginal.getMetadata().setResourceVersion(resourceVersion); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java index f76ec61e16..232b563e74 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java @@ -15,314 +15,294 @@ */ package io.javaoperatorsdk.operator.api.reconciler; -import java.util.Collections; -import java.util.List; -import java.util.function.UnaryOperator; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClientException; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.Resource; -import io.javaoperatorsdk.operator.TestUtils; -import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; -import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; -import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; -import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; - -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; class ReconcileUtilsTest { - private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; - - private Context context; - private KubernetesClient client; - private MixedOperation mixedOperation; - private Resource resourceOp; - private ControllerEventSource controllerEventSource; - private ControllerConfiguration controllerConfiguration; - - @BeforeEach - @SuppressWarnings("unchecked") - void setupMocks() { - context = mock(Context.class); - client = mock(KubernetesClient.class); - mixedOperation = mock(MixedOperation.class); - resourceOp = mock(Resource.class); - controllerEventSource = mock(ControllerEventSource.class); - controllerConfiguration = mock(ControllerConfiguration.class); - - var eventSourceRetriever = mock(EventSourceRetriever.class); - - when(context.getClient()).thenReturn(client); - when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); - when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME); - when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); - - when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); - when(mixedOperation.inNamespace(any())).thenReturn(mixedOperation); - when(mixedOperation.withName(any())).thenReturn(resourceOp); - } - - @Test - void addsFinalizer() { - var resource = TestUtils.testCustomResource1(); - resource.getMetadata().setResourceVersion("1"); - - when(context.getPrimaryResource()).thenReturn(resource); - - // Mock successful finalizer addition - when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) - .thenAnswer( - invocation -> { - var res = TestUtils.testCustomResource1(); - res.getMetadata().setResourceVersion("2"); - res.addFinalizer(FINALIZER_NAME); - return res; - }); - - var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); - - assertThat(result).isNotNull(); - assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); - assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - } - - @Test - void addsFinalizerWithSSA() { - var resource = TestUtils.testCustomResource1(); - resource.getMetadata().setResourceVersion("1"); - - when(context.getPrimaryResource()).thenReturn(resource); - - // Mock successful SSA finalizer addition - when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) - .thenAnswer( - invocation -> { - var res = TestUtils.testCustomResource1(); - res.getMetadata().setResourceVersion("2"); - res.addFinalizer(FINALIZER_NAME); - return res; - }); - - var result = ReconcileUtils.addFinalizerWithSSA(context, FINALIZER_NAME); - - assertThat(result).isNotNull(); - assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); - assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - } - - @Test - void removesFinalizer() { - var resource = TestUtils.testCustomResource1(); - resource.getMetadata().setResourceVersion("1"); - resource.addFinalizer(FINALIZER_NAME); - - when(context.getPrimaryResource()).thenReturn(resource); - - // Mock successful finalizer removal - when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) - .thenAnswer( - invocation -> { - var res = TestUtils.testCustomResource1(); - res.getMetadata().setResourceVersion("2"); - // finalizer is removed, so don't add it - return res; - }); - - var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); - - assertThat(result).isNotNull(); - assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); - assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - } - - @Test - void retriesAddingFinalizerWithoutSSA() { - var resource = TestUtils.testCustomResource1(); - resource.getMetadata().setResourceVersion("1"); - - when(context.getPrimaryResource()).thenReturn(resource); - - // First call throws conflict, second succeeds - when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) - .thenThrow(new KubernetesClientException("Conflict", 409, null)) - .thenAnswer( - invocation -> { - var res = TestUtils.testCustomResource1(); - res.getMetadata().setResourceVersion("2"); - res.addFinalizer(FINALIZER_NAME); - return res; - }); - - // Return fresh resource on retry - when(resourceOp.get()).thenReturn(resource); - - var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); - - assertThat(result).isNotNull(); - assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); - verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - verify(resourceOp, times(1)).get(); - } - - @Test - void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { - var resource = TestUtils.testCustomResource1(); - resource.getMetadata().setResourceVersion("1"); - resource.addFinalizer(FINALIZER_NAME); - - when(context.getPrimaryResource()).thenReturn(resource); - - // First call throws conflict - when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) - .thenThrow(new KubernetesClientException("Conflict", 409, null)); - - // Return null on retry (resource was deleted) - when(resourceOp.get()).thenReturn(null); - - ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); - - verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - verify(resourceOp, times(1)).get(); - } - - @Test - void retriesFinalizerRemovalWithFreshResource() { - var originalResource = TestUtils.testCustomResource1(); - originalResource.getMetadata().setResourceVersion("1"); - originalResource.addFinalizer(FINALIZER_NAME); - - when(context.getPrimaryResource()).thenReturn(originalResource); - - // First call throws unprocessable (422), second succeeds - when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) - .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) - .thenAnswer( - invocation -> { - var res = TestUtils.testCustomResource1(); - res.getMetadata().setResourceVersion("3"); - // finalizer should be removed - return res; - }); - - // Return fresh resource with newer version on retry - var freshResource = TestUtils.testCustomResource1(); - freshResource.getMetadata().setResourceVersion("2"); - freshResource.addFinalizer(FINALIZER_NAME); - when(resourceOp.get()).thenReturn(freshResource); - - var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); - - assertThat(result).isNotNull(); - assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); - assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); - verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - verify(resourceOp, times(1)).get(); - } - - @Test - void resourcePatchWithSingleEventSource() { - var resource = TestUtils.testCustomResource1(); - resource.getMetadata().setResourceVersion("1"); - - var updatedResource = TestUtils.testCustomResource1(); - updatedResource.getMetadata().setResourceVersion("2"); - - var eventSourceRetriever = mock(EventSourceRetriever.class); - var managedEventSource = mock(ManagedInformerEventSource.class); - - when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - .thenReturn(List.of(managedEventSource)); - when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) - .thenReturn(updatedResource); - - var result = ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity()); - - assertThat(result).isNotNull(); - assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - verify(managedEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - } - - @Test - void resourcePatchThrowsWhenNoEventSourceFound() { - var resource = TestUtils.testCustomResource1(); - var eventSourceRetriever = mock(EventSourceRetriever.class); - - when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - .thenReturn(Collections.emptyList()); - - var exception = - assertThrows( - IllegalStateException.class, - () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); - - assertThat(exception.getMessage()).contains("No event source found for type"); - } - - @Test - void resourcePatchThrowsWhenMultipleEventSourcesFound() { - var resource = TestUtils.testCustomResource1(); - var eventSourceRetriever = mock(EventSourceRetriever.class); - var eventSource1 = mock(ManagedInformerEventSource.class); - var eventSource2 = mock(ManagedInformerEventSource.class); - - when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - .thenReturn(List.of(eventSource1, eventSource2)); - - var exception = - assertThrows( - IllegalStateException.class, - () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); - - assertThat(exception.getMessage()).contains("Multiple event sources found for"); - assertThat(exception.getMessage()).contains("please provide the target event source"); - } - - @Test - void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { - var resource = TestUtils.testCustomResource1(); - var eventSourceRetriever = mock(EventSourceRetriever.class); - var nonManagedEventSource = mock(EventSource.class); - - when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - .thenReturn(List.of(nonManagedEventSource)); - - var exception = - assertThrows( - IllegalStateException.class, - () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); - - assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); - assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); - } + // private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; + // + // private Context context; + // private KubernetesClient client; + // private MixedOperation mixedOperation; + // private Resource resourceOp; + // private ControllerEventSource controllerEventSource; + // private ControllerConfiguration controllerConfiguration; + // + // @BeforeEach + // @SuppressWarnings("unchecked") + // void setupMocks() { + // context = mock(Context.class); + // client = mock(KubernetesClient.class); + // mixedOperation = mock(MixedOperation.class); + // resourceOp = mock(Resource.class); + // controllerEventSource = mock(ControllerEventSource.class); + // controllerConfiguration = mock(ControllerConfiguration.class); + // + // var eventSourceRetriever = mock(EventSourceRetriever.class); + // + // when(context.getClient()).thenReturn(client); + // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + // when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + // when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME); + // when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); + // + // when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); + // when(mixedOperation.inNamespace(any())).thenReturn(mixedOperation); + // when(mixedOperation.withName(any())).thenReturn(resourceOp); + // } + // + // @Test + // void addsFinalizer() { + // var resource = TestUtils.testCustomResource1(); + // resource.getMetadata().setResourceVersion("1"); + // + // when(context.getPrimaryResource()).thenReturn(resource); + // + // // Mock successful finalizer addition + // when(controllerEventSource.eventFilteringUpdateAndCacheResource( + // any(), any(UnaryOperator.class))) + // .thenAnswer( + // invocation -> { + // var res = TestUtils.testCustomResource1(); + // res.getMetadata().setResourceVersion("2"); + // res.addFinalizer(FINALIZER_NAME); + // return res; + // }); + // + // var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); + // + // assertThat(result).isNotNull(); + // assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + // verify(controllerEventSource, times(1)) + // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + // } + // + // @Test + // void addsFinalizerWithSSA() { + // var resource = TestUtils.testCustomResource1(); + // resource.getMetadata().setResourceVersion("1"); + // + // when(context.getPrimaryResource()).thenReturn(resource); + // + // // Mock successful SSA finalizer addition + // when(controllerEventSource.eventFilteringUpdateAndCacheResource( + // any(), any(UnaryOperator.class))) + // .thenAnswer( + // invocation -> { + // var res = TestUtils.testCustomResource1(); + // res.getMetadata().setResourceVersion("2"); + // res.addFinalizer(FINALIZER_NAME); + // return res; + // }); + // + // var result = ReconcileUtils.addFinalizerWithSSA(context, FINALIZER_NAME); + // + // assertThat(result).isNotNull(); + // assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + // verify(controllerEventSource, times(1)) + // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + // } + // + // @Test + // void removesFinalizer() { + // var resource = TestUtils.testCustomResource1(); + // resource.getMetadata().setResourceVersion("1"); + // resource.addFinalizer(FINALIZER_NAME); + // + // when(context.getPrimaryResource()).thenReturn(resource); + // + // // Mock successful finalizer removal + // when(controllerEventSource.eventFilteringUpdateAndCacheResource( + // any(), any(UnaryOperator.class))) + // .thenAnswer( + // invocation -> { + // var res = TestUtils.testCustomResource1(); + // res.getMetadata().setResourceVersion("2"); + // // finalizer is removed, so don't add it + // return res; + // }); + // + // var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + // + // assertThat(result).isNotNull(); + // assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + // verify(controllerEventSource, times(1)) + // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + // } + // + // @Test + // void retriesAddingFinalizerWithoutSSA() { + // var resource = TestUtils.testCustomResource1(); + // resource.getMetadata().setResourceVersion("1"); + // + // when(context.getPrimaryResource()).thenReturn(resource); + // + // // First call throws conflict, second succeeds + // when(controllerEventSource.eventFilteringUpdateAndCacheResource( + // any(), any(UnaryOperator.class))) + // .thenThrow(new KubernetesClientException("Conflict", 409, null)) + // .thenAnswer( + // invocation -> { + // var res = TestUtils.testCustomResource1(); + // res.getMetadata().setResourceVersion("2"); + // res.addFinalizer(FINALIZER_NAME); + // return res; + // }); + // + // // Return fresh resource on retry + // when(resourceOp.get()).thenReturn(resource); + // + // var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); + // + // assertThat(result).isNotNull(); + // assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + // verify(controllerEventSource, times(2)) + // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + // verify(resourceOp, times(1)).get(); + // } + // + // @Test + // void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { + // var resource = TestUtils.testCustomResource1(); + // resource.getMetadata().setResourceVersion("1"); + // resource.addFinalizer(FINALIZER_NAME); + // + // when(context.getPrimaryResource()).thenReturn(resource); + // + // // First call throws conflict + // when(controllerEventSource.eventFilteringUpdateAndCacheResource( + // any(), any(UnaryOperator.class))) + // .thenThrow(new KubernetesClientException("Conflict", 409, null)); + // + // // Return null on retry (resource was deleted) + // when(resourceOp.get()).thenReturn(null); + // + // ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + // + // verify(controllerEventSource, times(1)) + // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + // verify(resourceOp, times(1)).get(); + // } + // + // @Test + // void retriesFinalizerRemovalWithFreshResource() { + // var originalResource = TestUtils.testCustomResource1(); + // originalResource.getMetadata().setResourceVersion("1"); + // originalResource.addFinalizer(FINALIZER_NAME); + // + // when(context.getPrimaryResource()).thenReturn(originalResource); + // + // // First call throws unprocessable (422), second succeeds + // when(controllerEventSource.eventFilteringUpdateAndCacheResource( + // any(), any(UnaryOperator.class))) + // .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) + // .thenAnswer( + // invocation -> { + // var res = TestUtils.testCustomResource1(); + // res.getMetadata().setResourceVersion("3"); + // // finalizer should be removed + // return res; + // }); + // + // // Return fresh resource with newer version on retry + // var freshResource = TestUtils.testCustomResource1(); + // freshResource.getMetadata().setResourceVersion("2"); + // freshResource.addFinalizer(FINALIZER_NAME); + // when(resourceOp.get()).thenReturn(freshResource); + // + // var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + // + // assertThat(result).isNotNull(); + // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); + // assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + // verify(controllerEventSource, times(2)) + // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + // verify(resourceOp, times(1)).get(); + // } + // + // @Test + // void resourcePatchWithSingleEventSource() { + // var resource = TestUtils.testCustomResource1(); + // resource.getMetadata().setResourceVersion("1"); + // + // var updatedResource = TestUtils.testCustomResource1(); + // updatedResource.getMetadata().setResourceVersion("2"); + // + // var eventSourceRetriever = mock(EventSourceRetriever.class); + // var managedEventSource = mock(ManagedInformerEventSource.class); + // + // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + // .thenReturn(List.of(managedEventSource)); + // when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), + // any(UnaryOperator.class))) + // .thenReturn(updatedResource); + // + // var result = ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity()); + // + // assertThat(result).isNotNull(); + // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + // verify(managedEventSource, times(1)) + // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + // } + // + // @Test + // void resourcePatchThrowsWhenNoEventSourceFound() { + // var resource = TestUtils.testCustomResource1(); + // var eventSourceRetriever = mock(EventSourceRetriever.class); + // + // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + // .thenReturn(Collections.emptyList()); + // + // var exception = + // assertThrows( + // IllegalStateException.class, + // () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + // + // assertThat(exception.getMessage()).contains("No event source found for type"); + // } + // + // @Test + // void resourcePatchThrowsWhenMultipleEventSourcesFound() { + // var resource = TestUtils.testCustomResource1(); + // var eventSourceRetriever = mock(EventSourceRetriever.class); + // var eventSource1 = mock(ManagedInformerEventSource.class); + // var eventSource2 = mock(ManagedInformerEventSource.class); + // + // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + // .thenReturn(List.of(eventSource1, eventSource2)); + // + // var exception = + // assertThrows( + // IllegalStateException.class, + // () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + // + // assertThat(exception.getMessage()).contains("Multiple event sources found for"); + // assertThat(exception.getMessage()).contains("please provide the target event source"); + // } + // + // @Test + // void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { + // var resource = TestUtils.testCustomResource1(); + // var eventSourceRetriever = mock(EventSourceRetriever.class); + // var nonManagedEventSource = mock(EventSource.class); + // + // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + // .thenReturn(List.of(nonManagedEventSource)); + // + // var exception = + // assertThrows( + // IllegalStateException.class, + // () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + // + // assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); + // assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); + // } } From 11e52326493de90206c2a9a6bd711e1d9e7fe1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 13:25:58 +0100 Subject: [PATCH 02/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Context.java | 2 +- .../api/reconciler/DefaultContext.java | 8 +- ...entFacade.java => ResourceOperations.java} | 32 +++++-- .../event/ReconciliationDispatcher.java | 35 ++++---- .../event/ReconciliationDispatcherTest.java | 87 ++++++++++--------- .../ChangeNamespaceTestReconciler.java | 2 +- ...cKubernetesResourceHandlingReconciler.java | 2 +- ...ultipleSecondaryEventSourceReconciler.java | 5 +- .../baseapi/simple/TestReconciler.java | 4 +- ...TriggerReconcilerOnAllEventReconciler.java | 5 +- .../SelectiveFinalizerHandlingReconciler.java | 5 +- .../ExternalStateReconciler.java | 3 +- .../operator/sample/WebPageReconciler.java | 8 +- 13 files changed, 110 insertions(+), 88 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/{KubernetesClientFacade.java => ResourceOperations.java} (94%) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java index b10883c6b2..d390a5ad67 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -58,7 +58,7 @@ default Stream getSecondaryResourcesAsStream(Class expectedType) { KubernetesClient getClient(); - KubernetesClientFacade

getClientFacade(); + ResourceOperations

resourceOperations(); /** ExecutorService initialized by framework for workflows. Used for workflow standalone mode. */ ExecutorService getWorkflowExecutorService(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java index 7ac161e2d3..3c7d6319a6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -46,7 +46,7 @@ public class DefaultContext

implements Context

{ private final boolean primaryResourceDeleted; private final boolean primaryResourceFinalStateUnknown; private final Map, Object> desiredStates = new ConcurrentHashMap<>(); - private final KubernetesClientFacade

clientFacade; + private final ResourceOperations

resourceOperations; public DefaultContext( RetryInfo retryInfo, @@ -62,7 +62,7 @@ public DefaultContext( this.primaryResourceFinalStateUnknown = primaryResourceFinalStateUnknown; this.defaultManagedDependentResourceContext = new DefaultManagedWorkflowAndDependentResourceContext<>(controller, primaryResource, this); - this.clientFacade = new KubernetesClientFacade<>(this); + this.resourceOperations = new ResourceOperations<>(this); } @Override @@ -127,8 +127,8 @@ public KubernetesClient getClient() { } @Override - public KubernetesClientFacade

getClientFacade() { - return null; + public ResourceOperations

resourceOperations() { + return resourceOperations; } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/KubernetesClientFacade.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java similarity index 94% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/KubernetesClientFacade.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 348963ca16..dad2d4c7a8 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/KubernetesClientFacade.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -1,3 +1,18 @@ +/* + * 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.api.reconciler; import java.lang.reflect.InvocationTargetException; @@ -20,15 +35,15 @@ import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; -public class KubernetesClientFacade

{ +public class ResourceOperations

{ public static final int DEFAULT_MAX_RETRY = 10; - private static final Logger log = LoggerFactory.getLogger(KubernetesClientFacade.class); + private static final Logger log = LoggerFactory.getLogger(ResourceOperations.class); private final Context

context; - public KubernetesClientFacade(Context

context) { + public ResourceOperations(Context

context) { this.context = context; } @@ -301,7 +316,10 @@ public R jsonMergePatchPrimary(R resource) { * @see #jsonMergePatchPrimaryStatus(HasMetadata) */ public R jsonMergePatchPrimaryStatus(R resource) { - return jsonMergePatchPrimaryStatus(resource); + return resourcePatch( + resource, + r -> context.getClient().resource(r).patchStatus(), + context.eventSourceRetriever().getControllerEventSource()); } /** @@ -401,8 +419,8 @@ public P removeFinalizer() { } /** - * Removes the target finalizer from target resource. Uses JSON Patch and handles retries, see - * {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, HasMetadata, + * Removes the target finalizer from the primary resource. Uses JSON Patch and handles retries, + * see {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, HasMetadata, * UnaryOperator, Predicate)} for details. It does not try to remove finalizer if finalizer is not * present on the resource. * @@ -430,7 +448,7 @@ public P removeFinalizer(String finalizerName) { /** * Patches the resource using JSON Patch. In case the server responds with conflict (HTTP 409) or * unprocessable content (HTTP 422) it retries the operation up to the maximum number defined in - * {@link KubernetesClientFacade#DEFAULT_MAX_RETRY}. + * {@link ResourceOperations#DEFAULT_MAX_RETRY}. * * @param resourceChangesOperator changes to be done on the resource before update * @param preCondition condition to check if the patch operation still needs to be performed or 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 4d7a59e5c0..9e26833362 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 @@ -72,13 +72,14 @@ public ReconciliationDispatcher(Controller

controller) { public PostExecutionControl

handleExecution(ExecutionScope

executionScope) { validateExecutionScope(executionScope); try { - return handleDispatch(executionScope); + return handleDispatch(executionScope, null); } catch (Exception e) { return PostExecutionControl.exceptionDuringExecution(e); } } - private PostExecutionControl

handleDispatch(ExecutionScope

executionScope) + // visible for testing + PostExecutionControl

handleDispatch(ExecutionScope

executionScope, Context

context) throws Exception { P originalResource = executionScope.getResource(); var resourceForExecution = cloneResource(originalResource); @@ -97,13 +98,15 @@ && shouldNotDispatchToCleanupWhenMarkedForDeletion(originalResource)) { originalResource.getMetadata().getFinalizers()); return PostExecutionControl.defaultDispatch(); } - Context

context = - new DefaultContext<>( - executionScope.getRetryInfo(), - controller, - resourceForExecution, - executionScope.isDeleteEvent(), - executionScope.isDeleteFinalStateUnknown()); + context = + context == null + ? new DefaultContext<>( + executionScope.getRetryInfo(), + controller, + resourceForExecution, + executionScope.isDeleteEvent(), + executionScope.isDeleteFinalStateUnknown()) + : context; // checking the cleaner for all-event-mode if (!triggerOnAllEvents() && markedForDeletion) { @@ -136,9 +139,9 @@ private PostExecutionControl

handleReconcile( */ P updatedResource; if (useSSA) { - updatedResource = context.getClientFacade().addFinalizerWithSSA(); + updatedResource = context.resourceOperations().addFinalizerWithSSA(); } else { - updatedResource = context.getClientFacade().addFinalizer(); + updatedResource = context.resourceOperations().addFinalizer(); } return PostExecutionControl.onlyFinalizerAdded(updatedResource) .withReSchedule(BaseControl.INSTANT_RESCHEDULE); @@ -320,7 +323,7 @@ private PostExecutionControl

handleCleanup( // cleanup is finished, nothing left to be done final var finalizerName = configuration().getFinalizerName(); if (deleteControl.isRemoveFinalizer() && resourceForExecution.hasFinalizer(finalizerName)) { - P customResource = context.getClientFacade().removeFinalizer(); + P customResource = context.resourceOperations().removeFinalizer(); return PostExecutionControl.customResourceFinalizerRemoved(customResource); } } @@ -386,9 +389,9 @@ public R patchResource(Context context, R resource, R originalResource) { resource.getMetadata().getResourceVersion()); } if (useSSA) { - return context.getClientFacade().serverSideApplyPrimary(resource); + return context.resourceOperations().serverSideApplyPrimary(resource); } else { - return context.getClientFacade().jsonPatchPrimary(originalResource, r -> resource); + return context.resourceOperations().jsonPatchPrimary(originalResource, r -> resource); } } @@ -398,7 +401,7 @@ public R patchStatus(Context context, R resource, R originalResource) { var managedFields = resource.getMetadata().getManagedFields(); try { resource.getMetadata().setManagedFields(null); - return context.getClientFacade().serverSideApplyPrimaryStatus(resource); + return context.resourceOperations().serverSideApplyPrimaryStatus(resource); } finally { resource.getMetadata().setManagedFields(managedFields); } @@ -416,7 +419,7 @@ private R editStatus(Context context, R resource, R originalResource) { clonedOriginal.getMetadata().setResourceVersion(null); resource.getMetadata().setResourceVersion(null); return context - .getClientFacade() + .resourceOperations() .jsonPatchPrimaryStatus( clonedOriginal, r -> { 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 13673a72d5..c7d9458695 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 @@ -26,7 +26,6 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; -import org.mockito.MockedStatic; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.ObjectMeta; @@ -45,8 +44,8 @@ import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.Controller; @@ -72,6 +71,7 @@ class ReconciliationDispatcherTest { private final CustomResourceFacade customResourceFacade = mock(ReconciliationDispatcher.CustomResourceFacade.class); private static ConfigurationService configurationService; + private ResourceOperations mockResourceOperations; @BeforeEach void setup() { @@ -151,27 +151,25 @@ public boolean useFinalizer() { } @Test - void addFinalizerOnNewResource() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizerWithSSA(any()), times(1)); - } + void addFinalizerOnNewResource() throws Exception { + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), createTestContext()); + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(mockResourceOperations, times(1)).addFinalizerWithSSA(); } @Test - void addFinalizerOnNewResourceWithoutSSA() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - initConfigService(false, false); - final ReconciliationDispatcher dispatcher = - init(testCustomResource, reconciler, null, customResourceFacade, true); + void addFinalizerOnNewResourceWithoutSSA() throws Exception { + initConfigService(false, false); + final ReconciliationDispatcher dispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, true); + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizer(any()), times(1)); - } + dispatcher.handleDispatch(executionScopeWithCREvent(testCustomResource), createTestContext()); + + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(mockResourceOperations, times(1)).addFinalizer(); } @Test @@ -227,17 +225,16 @@ void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() { } @Test - void removesDefaultFinalizerOnDeleteIfSet() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); + void removesDefaultFinalizerOnDeleteIfSet() throws Exception { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + markForDeletion(testCustomResource); - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + var postExecControl = + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), createTestContext()); - assertThat(postExecControl.isFinalizerRemoved()).isTrue(); - mockedReconcileUtils.verify(() -> ReconcileUtils.removeFinalizer(any()), times(1)); - } + assertThat(postExecControl.isFinalizerRemoved()).isTrue(); + verify(mockResourceOperations, times(1)).removeFinalizer(); } @Test @@ -295,20 +292,21 @@ void doesNotUpdateTheResourceIfNoUpdateUpdateControlIfFinalizerSet() { } @Test - void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - removeFinalizers(testCustomResource); - reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); - mockedReconcileUtils - .when(() -> ReconcileUtils.addFinalizerWithSSA(any())) - .thenReturn(testCustomResource); - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() throws Exception { - mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizerWithSSA(any()), times(1)); - assertThat(postExecControl.updateIsStatusPatch()).isFalse(); - assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); - } + removeFinalizers(testCustomResource); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + var context = createTestContext(); + when(mockResourceOperations.addFinalizerWithSSA()).thenReturn(testCustomResource); + + var postExecControl = + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), context); + + verify(mockResourceOperations, times(1)).addFinalizerWithSSA(); + + assertThat(postExecControl.updateIsStatusPatch()).isFalse(); + assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); } @Test @@ -646,6 +644,13 @@ void reconcilerContextUsesTheSameInstanceOfResourceAsParam() { .isNotSameAs(testCustomResource); } + private Context createTestContext() { + var mockContext = mock(Context.class); + mockResourceOperations = mock(ResourceOperations.class); + when(mockContext.resourceOperations()).thenReturn(mockResourceOperations); + return mockContext; + } + private ObservedGenCustomResource createObservedGenCustomResource() { ObservedGenCustomResource observedGenCustomResource = new ObservedGenCustomResource(); observedGenCustomResource.setMetadata(new ObjectMeta()); 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 96bd43c9e2..d05364fc44 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 @@ -53,7 +53,7 @@ public UpdateControl reconcile( ChangeNamespaceTestCustomResource primary, Context context) { - ReconcileUtils.serverSideApply(context, configMap(primary)); + context.resourceOperations().serverSideApply(configMap(primary)); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); 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 f76443c103..7efa8a0ad6 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 @@ -40,7 +40,7 @@ public UpdateControl reconcile( GenericKubernetesResourceHandlingCustomResource primary, Context context) { - ReconcileUtils.serverSideApply(context, desiredConfigMap(primary, context)); + context.resourceOperations().serverSideApply(desiredConfigMap(primary, context)); return UpdateControl.noUpdate(); } 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 aea2dfe0c2..2a11be1faf 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 @@ -26,7 +26,6 @@ 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.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -54,8 +53,8 @@ public UpdateControl reconcile( Context context) { numberOfExecutions.addAndGet(1); - ReconcileUtils.serverSideApply(context, configMap(getName1(resource), resource)); - ReconcileUtils.serverSideApply(context, configMap(getName2(resource), resource)); + context.resourceOperations().serverSideApply(configMap(getName1(resource), resource)); + context.resourceOperations().serverSideApply(configMap(getName2(resource), resource)); if (numberOfExecutions.get() >= 3) { if (context.getSecondaryResources(ConfigMap.class).size() != 2) { 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 49dbe80554..974427ba43 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 @@ -69,7 +69,7 @@ public UpdateControl reconcile( if (existingConfigMap != null) { existingConfigMap.setData(configMapData(resource)); log.info("Updating config map"); - ReconcileUtils.serverSideApply(context, existingConfigMap); + context.resourceOperations().serverSideApply(existingConfigMap); } else { Map labels = new HashMap<>(); labels.put("managedBy", TestReconciler.class.getSimpleName()); @@ -84,7 +84,7 @@ public UpdateControl reconcile( .withData(configMapData(resource)) .build(); log.info("Creating config map"); - ReconcileUtils.serverSideApply(context, newConfigMap); + context.resourceOperations().serverSideApply(newConfigMap); } if (updateStatus) { var statusUpdateResource = new TestCustomResource(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java index 2217662402..f8804bd25d 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java @@ -22,7 +22,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -75,7 +74,7 @@ public UpdateControl reconcile( if (!primary.isMarkedForDeletion() && getUseFinalizer() && !primary.hasFinalizer(FINALIZER)) { log.info("Adding finalizer"); - ReconcileUtils.addFinalizer(context, FINALIZER); + context.resourceOperations().addFinalizer(FINALIZER); return UpdateControl.noUpdate(); } @@ -98,7 +97,7 @@ public UpdateControl reconcile( setEventOnMarkedForDeletion(true); if (getUseFinalizer() && primary.hasFinalizer(FINALIZER)) { log.info("Removing finalizer"); - ReconcileUtils.removeFinalizer(context, FINALIZER); + context.resourceOperations().removeFinalizer(FINALIZER); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java index f9198d0eae..a7bf76a6e7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java @@ -17,7 +17,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -37,11 +36,11 @@ public UpdateControl reconci } if (resource.getSpec().getUseFinalizer()) { - ReconcileUtils.addFinalizer(context, FINALIZER); + context.resourceOperations().addFinalizer(FINALIZER); } if (resource.isMarkedForDeletion()) { - ReconcileUtils.removeFinalizer(context, FINALIZER); + context.resourceOperations().removeFinalizer(FINALIZER); } return UpdateControl.noUpdate(); 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 b97d8ef679..4f4cab80d7 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 @@ -32,7 +32,6 @@ 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.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -111,7 +110,7 @@ private void createExternalResource( // This is critical in this case, since on next reconciliation if it would not be in the cache // it would be created again. configMapEventSource.eventFilteringUpdateAndCacheResource( - configMap, toCreate -> ReconcileUtils.serverSideApply(context, toCreate)); + configMap, toCreate -> context.resourceOperations().serverSideApply(toCreate)); externalResourceEventSource.handleRecentResourceCreate(primaryID, createdResource); } 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 13fede9fcc..4a1ed85aff 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 @@ -105,7 +105,7 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating ConfigMap {} in {}", desiredHtmlConfigMap.getMetadata().getName(), ns); - ReconcileUtils.serverSideApply(context, desiredHtmlConfigMap); + context.resourceOperations().serverSideApply(desiredHtmlConfigMap); } var existingDeployment = context.getSecondaryResource(Deployment.class).orElse(null); @@ -114,7 +114,7 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating Deployment {} in {}", desiredDeployment.getMetadata().getName(), ns); - ReconcileUtils.serverSideApply(context, desiredDeployment); + context.resourceOperations().serverSideApply(desiredDeployment); } var existingService = context.getSecondaryResource(Service.class).orElse(null); @@ -123,14 +123,14 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating Deployment {} in {}", desiredDeployment.getMetadata().getName(), ns); - ReconcileUtils.serverSideApply(context, desiredService); + context.resourceOperations().serverSideApply(desiredDeployment); } var existingIngress = context.getSecondaryResource(Ingress.class); if (Boolean.TRUE.equals(webPage.getSpec().getExposed())) { var desiredIngress = makeDesiredIngress(webPage); if (existingIngress.isEmpty() || !match(desiredIngress, existingIngress.get())) { - ReconcileUtils.serverSideApply(context, desiredIngress); + context.resourceOperations().serverSideApply(desiredDeployment); } } else existingIngress.ifPresent(ingress -> context.getClient().resource(ingress).delete()); From 668097f0a11cf142607381aa458dbb168d931c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 13:27:15 +0100 Subject: [PATCH 03/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index dad2d4c7a8..76551cff5d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -24,7 +24,6 @@ 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; @@ -387,9 +386,8 @@ public P addFinalizer() { /** * Adds finalizer to the resource using JSON Patch. Retries conflicts and unprocessable content - * (HTTP 422), see {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, - * HasMetadata, UnaryOperator, Predicate)} for details on retry. It does not try to add finalizer - * if there is already a finalizer or resource is marked for deletion. + * (HTTP 422). It does not try to add finalizer if there is already a finalizer or resource is + * marked for deletion. * * @return updated resource from the server response */ @@ -419,10 +417,8 @@ public P removeFinalizer() { } /** - * Removes the target finalizer from the primary resource. Uses JSON Patch and handles retries, - * see {@link PrimaryUpdateAndCacheUtils#conflictRetryingPatch(KubernetesClient, HasMetadata, - * UnaryOperator, Predicate)} for details. It does not try to remove finalizer if finalizer is not - * present on the resource. + * Removes the target finalizer from the primary resource. Uses JSON Patch and handles retries. It + * does not try to remove finalizer if finalizer is not present on the resource. * * @return updated resource from the server response */ @@ -516,10 +512,9 @@ public R conflictRetryingPatch( * String)} with the configured finalizer name. * * @return the patched resource from the server response - * @param

primary resource type * @see #addFinalizerWithSSA(String) */ - public

P addFinalizerWithSSA() { + public P addFinalizerWithSSA() { return addFinalizerWithSSA(context.getControllerConfiguration().getFinalizerName()); } @@ -530,9 +525,8 @@ public

P addFinalizerWithSSA() { * * @param finalizerName name of the finalizer to add * @return the patched resource from the server response - * @param

primary resource type */ - public

P addFinalizerWithSSA(String finalizerName) { + public P addFinalizerWithSSA(String finalizerName) { var originalResource = context.getPrimaryResource(); if (log.isDebugEnabled()) { log.debug( From fdec2fee2b0a34e27bbf12371015c979e7a3e221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 14:29:04 +0100 Subject: [PATCH 04/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../PrimaryUpdateAndCacheUtils.java | 4 +- .../api/reconciler/ReconcileUtils.java | 26 -- .../api/reconciler/ReconcileUtilsTest.java | 308 ------------------ 3 files changed, 2 insertions(+), 336 deletions(-) delete mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java delete mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java 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 31c825e673..f74cd49ee7 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 @@ -46,8 +46,8 @@ * If the update fails, it reads the primary resource from the cluster, applies the modifications * again and retries the update. * - * @deprecated Use {@link ReconcileUtils} that contains the more efficient up-to-date versions of - * the target utils. + * @deprecated Use {@link Context#resourceOperations()} that contains the more efficient up-to-date + * versions of methods. */ @Deprecated(forRemoval = true) public class PrimaryUpdateAndCacheUtils { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java deleted file mode 100644 index a5ae57a8bf..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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.api.reconciler; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ReconcileUtils { - - private static final Logger log = LoggerFactory.getLogger(ReconcileUtils.class); - - private ReconcileUtils() {} -} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java deleted file mode 100644 index 232b563e74..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java +++ /dev/null @@ -1,308 +0,0 @@ -/* - * 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.api.reconciler; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class ReconcileUtilsTest { - - // private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; - // - // private Context context; - // private KubernetesClient client; - // private MixedOperation mixedOperation; - // private Resource resourceOp; - // private ControllerEventSource controllerEventSource; - // private ControllerConfiguration controllerConfiguration; - // - // @BeforeEach - // @SuppressWarnings("unchecked") - // void setupMocks() { - // context = mock(Context.class); - // client = mock(KubernetesClient.class); - // mixedOperation = mock(MixedOperation.class); - // resourceOp = mock(Resource.class); - // controllerEventSource = mock(ControllerEventSource.class); - // controllerConfiguration = mock(ControllerConfiguration.class); - // - // var eventSourceRetriever = mock(EventSourceRetriever.class); - // - // when(context.getClient()).thenReturn(client); - // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - // when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); - // when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME); - // when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); - // - // when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); - // when(mixedOperation.inNamespace(any())).thenReturn(mixedOperation); - // when(mixedOperation.withName(any())).thenReturn(resourceOp); - // } - // - // @Test - // void addsFinalizer() { - // var resource = TestUtils.testCustomResource1(); - // resource.getMetadata().setResourceVersion("1"); - // - // when(context.getPrimaryResource()).thenReturn(resource); - // - // // Mock successful finalizer addition - // when(controllerEventSource.eventFilteringUpdateAndCacheResource( - // any(), any(UnaryOperator.class))) - // .thenAnswer( - // invocation -> { - // var res = TestUtils.testCustomResource1(); - // res.getMetadata().setResourceVersion("2"); - // res.addFinalizer(FINALIZER_NAME); - // return res; - // }); - // - // var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); - // - // assertThat(result).isNotNull(); - // assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); - // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - // verify(controllerEventSource, times(1)) - // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - // } - // - // @Test - // void addsFinalizerWithSSA() { - // var resource = TestUtils.testCustomResource1(); - // resource.getMetadata().setResourceVersion("1"); - // - // when(context.getPrimaryResource()).thenReturn(resource); - // - // // Mock successful SSA finalizer addition - // when(controllerEventSource.eventFilteringUpdateAndCacheResource( - // any(), any(UnaryOperator.class))) - // .thenAnswer( - // invocation -> { - // var res = TestUtils.testCustomResource1(); - // res.getMetadata().setResourceVersion("2"); - // res.addFinalizer(FINALIZER_NAME); - // return res; - // }); - // - // var result = ReconcileUtils.addFinalizerWithSSA(context, FINALIZER_NAME); - // - // assertThat(result).isNotNull(); - // assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); - // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - // verify(controllerEventSource, times(1)) - // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - // } - // - // @Test - // void removesFinalizer() { - // var resource = TestUtils.testCustomResource1(); - // resource.getMetadata().setResourceVersion("1"); - // resource.addFinalizer(FINALIZER_NAME); - // - // when(context.getPrimaryResource()).thenReturn(resource); - // - // // Mock successful finalizer removal - // when(controllerEventSource.eventFilteringUpdateAndCacheResource( - // any(), any(UnaryOperator.class))) - // .thenAnswer( - // invocation -> { - // var res = TestUtils.testCustomResource1(); - // res.getMetadata().setResourceVersion("2"); - // // finalizer is removed, so don't add it - // return res; - // }); - // - // var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); - // - // assertThat(result).isNotNull(); - // assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); - // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - // verify(controllerEventSource, times(1)) - // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - // } - // - // @Test - // void retriesAddingFinalizerWithoutSSA() { - // var resource = TestUtils.testCustomResource1(); - // resource.getMetadata().setResourceVersion("1"); - // - // when(context.getPrimaryResource()).thenReturn(resource); - // - // // First call throws conflict, second succeeds - // when(controllerEventSource.eventFilteringUpdateAndCacheResource( - // any(), any(UnaryOperator.class))) - // .thenThrow(new KubernetesClientException("Conflict", 409, null)) - // .thenAnswer( - // invocation -> { - // var res = TestUtils.testCustomResource1(); - // res.getMetadata().setResourceVersion("2"); - // res.addFinalizer(FINALIZER_NAME); - // return res; - // }); - // - // // Return fresh resource on retry - // when(resourceOp.get()).thenReturn(resource); - // - // var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); - // - // assertThat(result).isNotNull(); - // assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); - // verify(controllerEventSource, times(2)) - // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - // verify(resourceOp, times(1)).get(); - // } - // - // @Test - // void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { - // var resource = TestUtils.testCustomResource1(); - // resource.getMetadata().setResourceVersion("1"); - // resource.addFinalizer(FINALIZER_NAME); - // - // when(context.getPrimaryResource()).thenReturn(resource); - // - // // First call throws conflict - // when(controllerEventSource.eventFilteringUpdateAndCacheResource( - // any(), any(UnaryOperator.class))) - // .thenThrow(new KubernetesClientException("Conflict", 409, null)); - // - // // Return null on retry (resource was deleted) - // when(resourceOp.get()).thenReturn(null); - // - // ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); - // - // verify(controllerEventSource, times(1)) - // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - // verify(resourceOp, times(1)).get(); - // } - // - // @Test - // void retriesFinalizerRemovalWithFreshResource() { - // var originalResource = TestUtils.testCustomResource1(); - // originalResource.getMetadata().setResourceVersion("1"); - // originalResource.addFinalizer(FINALIZER_NAME); - // - // when(context.getPrimaryResource()).thenReturn(originalResource); - // - // // First call throws unprocessable (422), second succeeds - // when(controllerEventSource.eventFilteringUpdateAndCacheResource( - // any(), any(UnaryOperator.class))) - // .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) - // .thenAnswer( - // invocation -> { - // var res = TestUtils.testCustomResource1(); - // res.getMetadata().setResourceVersion("3"); - // // finalizer should be removed - // return res; - // }); - // - // // Return fresh resource with newer version on retry - // var freshResource = TestUtils.testCustomResource1(); - // freshResource.getMetadata().setResourceVersion("2"); - // freshResource.addFinalizer(FINALIZER_NAME); - // when(resourceOp.get()).thenReturn(freshResource); - // - // var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); - // - // assertThat(result).isNotNull(); - // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); - // assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); - // verify(controllerEventSource, times(2)) - // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - // verify(resourceOp, times(1)).get(); - // } - // - // @Test - // void resourcePatchWithSingleEventSource() { - // var resource = TestUtils.testCustomResource1(); - // resource.getMetadata().setResourceVersion("1"); - // - // var updatedResource = TestUtils.testCustomResource1(); - // updatedResource.getMetadata().setResourceVersion("2"); - // - // var eventSourceRetriever = mock(EventSourceRetriever.class); - // var managedEventSource = mock(ManagedInformerEventSource.class); - // - // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - // .thenReturn(List.of(managedEventSource)); - // when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), - // any(UnaryOperator.class))) - // .thenReturn(updatedResource); - // - // var result = ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity()); - // - // assertThat(result).isNotNull(); - // assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - // verify(managedEventSource, times(1)) - // .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); - // } - // - // @Test - // void resourcePatchThrowsWhenNoEventSourceFound() { - // var resource = TestUtils.testCustomResource1(); - // var eventSourceRetriever = mock(EventSourceRetriever.class); - // - // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - // .thenReturn(Collections.emptyList()); - // - // var exception = - // assertThrows( - // IllegalStateException.class, - // () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); - // - // assertThat(exception.getMessage()).contains("No event source found for type"); - // } - // - // @Test - // void resourcePatchThrowsWhenMultipleEventSourcesFound() { - // var resource = TestUtils.testCustomResource1(); - // var eventSourceRetriever = mock(EventSourceRetriever.class); - // var eventSource1 = mock(ManagedInformerEventSource.class); - // var eventSource2 = mock(ManagedInformerEventSource.class); - // - // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - // .thenReturn(List.of(eventSource1, eventSource2)); - // - // var exception = - // assertThrows( - // IllegalStateException.class, - // () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); - // - // assertThat(exception.getMessage()).contains("Multiple event sources found for"); - // assertThat(exception.getMessage()).contains("please provide the target event source"); - // } - // - // @Test - // void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { - // var resource = TestUtils.testCustomResource1(); - // var eventSourceRetriever = mock(EventSourceRetriever.class); - // var nonManagedEventSource = mock(EventSource.class); - // - // when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); - // when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) - // .thenReturn(List.of(nonManagedEventSource)); - // - // var exception = - // assertThrows( - // IllegalStateException.class, - // () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); - // - // assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); - // assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); - // } -} From d6ad852d6d9ff40e651bee6e6119f5363053dfbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 14:37:05 +0100 Subject: [PATCH 05/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../javaoperatorsdk/operator/sample/WebPageReconciler.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 4a1ed85aff..f46ccb193e 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 @@ -120,10 +120,8 @@ public UpdateControl reconcile(WebPage webPage, Context contex var existingService = context.getSecondaryResource(Service.class).orElse(null); if (!match(desiredService, existingService)) { log.info( - "Creating or updating Deployment {} in {}", - desiredDeployment.getMetadata().getName(), - ns); - context.resourceOperations().serverSideApply(desiredDeployment); + "Creating or updating Service {} in {}", desiredDeployment.getMetadata().getName(), ns); + context.resourceOperations().serverSideApply(desiredService); } var existingIngress = context.getSecondaryResource(Ingress.class); From 875fdd2d5e8b86808fd83a3abf1e79010cd71999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 14:48:38 +0100 Subject: [PATCH 06/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 171 ++++++++++++++---- 1 file changed, 138 insertions(+), 33 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 76551cff5d..37e18a1ba5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -48,10 +48,9 @@ public ResourceOperations(Context

context) { /** * Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will contain to updated resource. Or more recent one if someone did an update - * after our update. - * - *

Optionally also can filter out the event, what is the result of this update. + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from the update, so + * reconciliation is not triggered by own update. * *

You are free to control the optimistic locking by setting the resource version in resource * metadata. In case of SSA we advise not to do updates with optimistic locking. @@ -77,8 +76,15 @@ public R serverSideApply(R resource) { } /** - * Server-Side Apply the resource status subresource. Updates the resource status and caches the - * response if needed, ensuring the next reconciliation will contain the updated resource. + * Server-Side Apply the resource status subresource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. In case of SSA we advise not to do updates with optimistic locking. * * @param resource fresh resource for server side apply * @return updated resource @@ -102,9 +108,15 @@ public R serverSideApplyStatus(R resource) { } /** - * Server-Side Apply the primary resource. Updates the primary resource and caches the response - * using the controller's event source, ensuring the next reconciliation will contain the updated - * resource. + * Server-Side Apply the primary resource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. In case of SSA we advise not to do updates with optimistic locking. * * @param resource primary resource for server side apply * @return updated resource @@ -127,8 +139,15 @@ public

P serverSideApplyPrimary(P resource) { } /** - * Server-Side Apply the primary resource status subresource. Updates the primary resource status - * and caches the response using the controller's event source. + * Server-Side Apply the primary resource status subresource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. In case of SSA we advise not to do updates with optimistic locking. * * @param resource primary resource for server side apply * @return updated resource @@ -152,8 +171,13 @@ public

P serverSideApplyPrimaryStatus(P resource) { } /** - * Updates the resource with optimistic locking based on the resource version. Caches the response - * if needed, ensuring the next reconciliation will contain the updated resource. + * Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource resource to update * @return updated resource @@ -164,7 +188,15 @@ public R update(R resource) { } /** - * Updates the resource status subresource with optimistic locking. Caches the response if needed. + * Updates the resource status subresource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource resource to update * @return updated resource @@ -175,8 +207,15 @@ public R updateStatus(R resource) { } /** - * Updates the primary resource with optimistic locking. Caches the response using the - * controller's event source. + * Updates the primary resource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource primary resource to update * @return updated resource @@ -190,8 +229,15 @@ public R updatePrimary(R resource) { } /** - * Updates the primary resource status subresource with optimistic locking. Caches the response - * using the controller's event source. + * Updates the primary resource status subresource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource primary resource to update * @return updated resource @@ -208,6 +254,14 @@ public R updatePrimaryStatus(R resource) { * Applies a JSON Patch to the resource. The unaryOperator function is used to modify the * resource, and the differences are sent as a JSON Patch to the Kubernetes API server. * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. + * * @param resource resource to patch * @param unaryOperator function to modify the resource * @return updated resource @@ -222,6 +276,14 @@ public R jsonPatch(R resource, UnaryOperator unaryOpe * Applies a JSON Patch to the resource status subresource. The unaryOperator function is used to * modify the resource status, and the differences are sent as a JSON Patch. * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. + * * @param resource resource to patch * @param unaryOperator function to modify the resource * @return updated resource @@ -233,8 +295,15 @@ public R jsonPatchStatus(R resource, UnaryOperator un } /** - * Applies a JSON Patch to the primary resource. Caches the response using the controller's event - * source. + * Applies a JSON Patch to the primary resource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource primary resource to patch * @param unaryOperator function to modify the resource @@ -249,8 +318,15 @@ public R jsonPatchPrimary(R resource, UnaryOperator u } /** - * Applies a JSON Patch to the primary resource status subresource. Caches the response using the - * controller's event source. + * Applies a JSON Patch to the primary resource status subresource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource primary resource to patch * @param unaryOperator function to modify the resource @@ -269,6 +345,14 @@ public R jsonPatchPrimaryStatus( * Applies a JSON Merge Patch to the resource. JSON Merge Patch (RFC 7386) is a simpler patching * strategy that merges the provided resource with the existing resource on the server. * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. + * * @param resource resource to patch * @return updated resource * @param resource type @@ -278,8 +362,15 @@ public R jsonMergePatch(R resource) { } /** - * Applies a JSON Merge Patch to the resource status subresource. Merges the provided resource - * status with the existing resource status on the server. + * Applies a JSON Merge Patch to the resource status subresource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource resource to patch * @return updated resource @@ -293,6 +384,14 @@ public R jsonMergePatchStatus(R resource) { * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's * event source. * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. + * * @param resource primary resource to patch reconciliation * @return updated resource * @param resource type @@ -305,9 +404,15 @@ public R jsonMergePatchPrimary(R resource) { } /** - * Applies a JSON Merge Patch to the primary resource status subresource and filters out the - * resulting event. This is a convenience method that calls {@link - * #jsonMergePatchPrimaryStatus(HasMetadata)} with filterEvent set to true. + * Applies a JSON Merge Patch to the primary resource. + * + *

Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will see to updated resource - or more recent one if additional update happened + * after this update; In addition to that it filters out the event from this update, so + * reconciliation is not triggered by own update. + * + *

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * * @param resource primary resource to patch * @return updated resource @@ -322,9 +427,9 @@ public R jsonMergePatchPrimaryStatus(R resource) { } /** - * Internal utility method to patch a resource and cache the result. Automatically discovers the - * event source for the resource type and delegates to {@link #resourcePatch(HasMetadata, - * UnaryOperator, ManagedInformerEventSource)}. + * Utility method to patch a resource and cache the result. Automatically discovers the event + * source for the resource type and delegates to {@link #resourcePatch(HasMetadata, UnaryOperator, + * ManagedInformerEventSource)}. * * @param context of reconciler * @param resource resource to patch @@ -357,9 +462,9 @@ public R resourcePatch( } /** - * Internal utility method to patch a resource and cache the result using the specified event - * source. This method either filters out the resulting event or allows it to trigger - * reconciliation based on the filterEvent parameter. + * Utility method to patch a resource and cache the result using the specified event source. This + * method either filters out the resulting event or allows it to trigger reconciliation based on + * the filterEvent parameter. * * @param resource resource to patch * @param updateOperation operation to perform (update, patch, edit, etc.) From bbb63a5b204f0b186692992292011bc6e55d1c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 14:49:50 +0100 Subject: [PATCH 07/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../reconciler/ResourceOperationsTest.java | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java new file mode 100644 index 0000000000..692c5c4a46 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -0,0 +1,330 @@ +/* + * 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.api.reconciler; + +import java.util.Collections; +import java.util.List; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ResourceOperationsTest { + + private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; + + private Context context; + private KubernetesClient client; + private MixedOperation mixedOperation; + private Resource resourceOp; + private ControllerEventSource controllerEventSource; + private ControllerConfiguration controllerConfiguration; + private ResourceOperations resourceOperations; + + @BeforeEach + @SuppressWarnings("unchecked") + void setupMocks() { + context = mock(Context.class); + client = mock(KubernetesClient.class); + mixedOperation = mock(MixedOperation.class); + resourceOp = mock(Resource.class); + controllerEventSource = mock(ControllerEventSource.class); + controllerConfiguration = mock(ControllerConfiguration.class); + + var eventSourceRetriever = mock(EventSourceRetriever.class); + + when(context.getClient()).thenReturn(client); + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME); + when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); + + when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); + when(mixedOperation.inNamespace(any())).thenReturn(mixedOperation); + when(mixedOperation.withName(any())).thenReturn(resourceOp); + + resourceOperations = new ResourceOperations<>(context); + } + + @Test + void addsFinalizer() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful finalizer addition + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + var result = resourceOperations.addFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void addsFinalizerWithSSA() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful SSA finalizer addition + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + var result = resourceOperations.addFinalizerWithSSA(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void removesFinalizer() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + resource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful finalizer removal + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + // finalizer is removed, so don't add it + return res; + }); + + var result = resourceOperations.removeFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void retriesAddingFinalizerWithoutSSA() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // First call throws conflict, second succeeds + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Conflict", 409, null)) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + // Return fresh resource on retry + when(resourceOp.get()).thenReturn(resource); + + var result = resourceOperations.addFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + verify(controllerEventSource, times(2)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + resource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(resource); + + // First call throws conflict + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Conflict", 409, null)); + + // Return null on retry (resource was deleted) + when(resourceOp.get()).thenReturn(null); + + resourceOperations.removeFinalizer(FINALIZER_NAME); + + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void retriesFinalizerRemovalWithFreshResource() { + var originalResource = TestUtils.testCustomResource1(); + originalResource.getMetadata().setResourceVersion("1"); + originalResource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(originalResource); + + // First call throws unprocessable (422), second succeeds + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("3"); + // finalizer should be removed + return res; + }); + + // Return fresh resource with newer version on retry + var freshResource = TestUtils.testCustomResource1(); + freshResource.getMetadata().setResourceVersion("2"); + freshResource.addFinalizer(FINALIZER_NAME); + when(resourceOp.get()).thenReturn(freshResource); + + var result = resourceOperations.removeFinalizer(FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + verify(controllerEventSource, times(2)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void resourcePatchWithSingleEventSource() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + var updatedResource = TestUtils.testCustomResource1(); + updatedResource.getMetadata().setResourceVersion("2"); + + var eventSourceRetriever = mock(EventSourceRetriever.class); + var managedEventSource = mock(ManagedInformerEventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(managedEventSource)); + when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + .thenReturn(updatedResource); + + var result = resourceOperations.resourcePatch(context, resource, UnaryOperator.identity()); + + assertThat(result).isNotNull(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(managedEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void resourcePatchThrowsWhenNoEventSourceFound() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(Collections.emptyList()); + + var exception = + assertThrows( + IllegalStateException.class, + () -> resourceOperations.resourcePatch(context, resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("No event source found for type"); + } + + @Test + void resourcePatchThrowsWhenMultipleEventSourcesFound() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + var eventSource1 = mock(ManagedInformerEventSource.class); + var eventSource2 = mock(ManagedInformerEventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(eventSource1, eventSource2)); + + var exception = + assertThrows( + IllegalStateException.class, + () -> resourceOperations.resourcePatch(context, resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("Multiple event sources found for"); + assertThat(exception.getMessage()).contains("please provide the target event source"); + } + + @Test + void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + var nonManagedEventSource = mock(EventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(nonManagedEventSource)); + + var exception = + assertThrows( + IllegalStateException.class, + () -> resourceOperations.resourcePatch(context, resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); + assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); + } +} From 8b3a4c750d9408765078f48525ef50afd14a8eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 14:50:45 +0100 Subject: [PATCH 08/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/event/ReconciliationDispatcher.java | 1 + 1 file changed, 1 insertion(+) 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 9e26833362..010b161979 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 @@ -98,6 +98,7 @@ && shouldNotDispatchToCleanupWhenMarkedForDeletion(originalResource)) { originalResource.getMetadata().getFinalizers()); return PostExecutionControl.defaultDispatch(); } + // context can be provided only for testing purposes context = context == null ? new DefaultContext<>( From c9ced8a1790099134177a1fb0785490bf1107983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 14:58:53 +0100 Subject: [PATCH 09/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 37e18a1ba5..53a0baf630 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -481,6 +481,8 @@ public R resourcePatch( /** * Adds the default finalizer (from controller configuration) to the primary resource. This is a * convenience method that calls {@link #addFinalizer(String)} with the configured finalizer name. + * Note that explicitly adding/removing finalizer is required only if "Trigger reconciliation on + * all event" mode is on. * * @return updated resource from the server response * @see #addFinalizer(String) @@ -492,7 +494,8 @@ public P addFinalizer() { /** * Adds finalizer to the resource using JSON Patch. Retries conflicts and unprocessable content * (HTTP 422). It does not try to add finalizer if there is already a finalizer or resource is - * marked for deletion. + * marked for deletion. Note that explicitly adding/removing finalizer is required only if + * "Trigger reconciliation on all event" mode is on. * * @return updated resource from the server response */ @@ -512,7 +515,8 @@ public P addFinalizer(String finalizerName) { /** * Removes the default finalizer (from controller configuration) from the primary resource. This * is a convenience method that calls {@link #removeFinalizer(String)} with the configured - * finalizer name. + * finalizer name. Note that explicitly adding/removing finalizer is required only if "Trigger + * reconciliation on all event" mode is on. * * @return updated resource from the server response * @see #removeFinalizer(String) @@ -523,7 +527,9 @@ public P removeFinalizer() { /** * Removes the target finalizer from the primary resource. Uses JSON Patch and handles retries. It - * does not try to remove finalizer if finalizer is not present on the resource. + * does not try to remove finalizer if finalizer is not present on the resource. Note that + * explicitly adding/removing finalizer is required only if "Trigger reconciliation on all event" + * mode is on. * * @return updated resource from the server response */ @@ -614,7 +620,8 @@ public R conflictRetryingPatch( /** * Adds the default finalizer (from controller configuration) to the primary resource using * Server-Side Apply. This is a convenience method that calls {@link #addFinalizerWithSSA( - * String)} with the configured finalizer name. + * String)} with the configured finalizer name. Note that explicitly adding finalizer is required + * only if "Trigger reconciliation on all event" mode is on. * * @return the patched resource from the server response * @see #addFinalizerWithSSA(String) @@ -626,7 +633,8 @@ public P addFinalizerWithSSA() { /** * Adds finalizer using Server-Side Apply. In the background this method creates a fresh copy of * the target resource, setting only name, namespace and finalizer. Does not use optimistic - * locking for the patch. + * locking for the patch. Note that explicitly adding finalizer is required only if "Trigger + * reconciliation on all event" mode is on. * * @param finalizerName name of the finalizer to add * @return the patched resource from the server response From 40d18e2f4b93298c4990974a7437fb992fe11c08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 30 Jan 2026 15:42:13 +0100 Subject: [PATCH 10/11] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 20 +++++++------------ .../reconciler/ResourceOperationsTest.java | 8 ++++---- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 53a0baf630..6ca71e7ecd 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -61,7 +61,6 @@ public ResourceOperations(Context

context) { */ public R serverSideApply(R resource) { return resourcePatch( - context, resource, r -> context @@ -92,7 +91,6 @@ public R serverSideApply(R resource) { */ public R serverSideApplyStatus(R resource) { return resourcePatch( - context, resource, r -> context @@ -184,7 +182,7 @@ public

P serverSideApplyPrimaryStatus(P resource) { * @param resource type */ public R update(R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).update()); + return resourcePatch(resource, r -> context.getClient().resource(r).update()); } /** @@ -203,7 +201,7 @@ public R update(R resource) { * @param resource type */ public R updateStatus(R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).updateStatus()); + return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus()); } /** @@ -268,8 +266,7 @@ public R updatePrimaryStatus(R resource) { * @param resource type */ public R jsonPatch(R resource, UnaryOperator unaryOperator) { - return resourcePatch( - context, resource, r -> context.getClient().resource(r).edit(unaryOperator)); + return resourcePatch(resource, r -> context.getClient().resource(r).edit(unaryOperator)); } /** @@ -290,8 +287,7 @@ public R jsonPatch(R resource, UnaryOperator unaryOpe * @param resource type */ public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { - return resourcePatch( - context, resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); + return resourcePatch(resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); } /** @@ -358,7 +354,7 @@ public R jsonPatchPrimaryStatus( * @param resource type */ public R jsonMergePatch(R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).patch()); + return resourcePatch(resource, r -> context.getClient().resource(r).patch()); } /** @@ -377,7 +373,7 @@ public R jsonMergePatch(R resource) { * @param resource type */ public R jsonMergePatchStatus(R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).patchStatus()); + return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus()); } /** @@ -431,15 +427,13 @@ public R jsonMergePatchPrimaryStatus(R resource) { * source for the resource type and delegates to {@link #resourcePatch(HasMetadata, UnaryOperator, * ManagedInformerEventSource)}. * - * @param context of reconciler * @param resource resource to patch * @param updateOperation operation to perform (update, patch, edit, etc.) * @return updated resource * @param resource type * @throws IllegalStateException if no event source or multiple event sources are found */ - public R resourcePatch( - Context context, R resource, UnaryOperator updateOperation) { + public R resourcePatch(R resource, UnaryOperator updateOperation) { var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); if (esList.isEmpty()) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 692c5c4a46..82ecf8996c 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -264,7 +264,7 @@ void resourcePatchWithSingleEventSource() { when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) .thenReturn(updatedResource); - var result = resourceOperations.resourcePatch(context, resource, UnaryOperator.identity()); + var result = resourceOperations.resourcePatch(resource, UnaryOperator.identity()); assertThat(result).isNotNull(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); @@ -284,7 +284,7 @@ void resourcePatchThrowsWhenNoEventSourceFound() { var exception = assertThrows( IllegalStateException.class, - () -> resourceOperations.resourcePatch(context, resource, UnaryOperator.identity())); + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); assertThat(exception.getMessage()).contains("No event source found for type"); } @@ -303,7 +303,7 @@ void resourcePatchThrowsWhenMultipleEventSourcesFound() { var exception = assertThrows( IllegalStateException.class, - () -> resourceOperations.resourcePatch(context, resource, UnaryOperator.identity())); + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); assertThat(exception.getMessage()).contains("Multiple event sources found for"); assertThat(exception.getMessage()).contains("please provide the target event source"); @@ -322,7 +322,7 @@ void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { var exception = assertThrows( IllegalStateException.class, - () -> resourceOperations.resourcePatch(context, resource, UnaryOperator.identity())); + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); From a90cb6a3e117192a72d6bba79b29a162c74f229a Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Fri, 30 Jan 2026 18:08:32 +0100 Subject: [PATCH 11/11] fix: make it more explicit when primary is being used Signed-off-by: Chris Laprun --- .../api/reconciler/ResourceOperations.java | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 6ca71e7ecd..3fe3864403 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -23,7 +23,6 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.base.PatchContext; import io.fabric8.kubernetes.client.dsl.base.PatchType; @@ -34,6 +33,13 @@ import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; +/** + * Provides useful operations to manipulate resources (server-side apply, patch, etc.) in an + * idiomatic way, in particular to make sure that the latest version of the resource is present in + * the caches for the next reconciliation. + * + * @param

the resource type on which this object operates + */ public class ResourceOperations

{ public static final int DEFAULT_MAX_RETRY = 10; @@ -118,9 +124,8 @@ public R serverSideApplyStatus(R resource) { * * @param resource primary resource for server side apply * @return updated resource - * @param

primary resource type */ - public

P serverSideApplyPrimary(P resource) { + public P serverSideApplyPrimary(P resource) { return resourcePatch( resource, r -> @@ -149,9 +154,8 @@ public

P serverSideApplyPrimary(P resource) { * * @param resource primary resource for server side apply * @return updated resource - * @param

primary resource type */ - public

P serverSideApplyPrimaryStatus(P resource) { + public P serverSideApplyPrimaryStatus(P resource) { return resourcePatch( resource, r -> @@ -217,9 +221,8 @@ public R updateStatus(R resource) { * * @param resource primary resource to update * @return updated resource - * @param resource type */ - public R updatePrimary(R resource) { + public P updatePrimary(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).update(), @@ -239,9 +242,8 @@ public R updatePrimary(R resource) { * * @param resource primary resource to update * @return updated resource - * @param resource type */ - public R updatePrimaryStatus(R resource) { + public P updatePrimaryStatus(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).updateStatus(), @@ -304,9 +306,8 @@ public R jsonPatchStatus(R resource, UnaryOperator un * @param resource primary resource to patch * @param unaryOperator function to modify the resource * @return updated resource - * @param resource type */ - public R jsonPatchPrimary(R resource, UnaryOperator unaryOperator) { + public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { return resourcePatch( resource, r -> context.getClient().resource(r).edit(unaryOperator), @@ -327,10 +328,8 @@ public R jsonPatchPrimary(R resource, UnaryOperator u * @param resource primary resource to patch * @param unaryOperator function to modify the resource * @return updated resource - * @param resource type */ - public R jsonPatchPrimaryStatus( - R resource, UnaryOperator unaryOperator) { + public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { return resourcePatch( resource, r -> context.getClient().resource(r).editStatus(unaryOperator), @@ -390,9 +389,8 @@ public R jsonMergePatchStatus(R resource) { * * @param resource primary resource to patch reconciliation * @return updated resource - * @param resource type */ - public R jsonMergePatchPrimary(R resource) { + public P jsonMergePatchPrimary(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).patch(), @@ -412,10 +410,9 @@ public R jsonMergePatchPrimary(R resource) { * * @param resource primary resource to patch * @return updated resource - * @param resource type * @see #jsonMergePatchPrimaryStatus(HasMetadata) */ - public R jsonMergePatchPrimaryStatus(R resource) { + public P jsonMergePatchPrimaryStatus(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).patchStatus(), @@ -433,6 +430,7 @@ public R jsonMergePatchPrimaryStatus(R resource) { * @param resource type * @throws IllegalStateException if no event source or multiple event sources are found */ + @SuppressWarnings({"rawtypes", "unchecked"}) public R resourcePatch(R resource, UnaryOperator updateOperation) { var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); @@ -447,7 +445,7 @@ public R resourcePatch(R resource, UnaryOperator upda } var es = esList.get(0); if (es instanceof ManagedInformerEventSource mes) { - return resourcePatch(resource, updateOperation, mes); + return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes); } else { throw new IllegalStateException( "Target event source must be a subclass off " @@ -466,10 +464,9 @@ public R resourcePatch(R resource, UnaryOperator upda * @return updated resource * @param resource type */ - @SuppressWarnings("unchecked") public R resourcePatch( - R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { - return (R) ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); + R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { + return ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); } /** @@ -498,7 +495,7 @@ public P addFinalizer(String finalizerName) { if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) { return resource; } - return conflictRetryingPatch( + return conflictRetryingPatchPrimary( r -> { r.addFinalizer(finalizerName); return r; @@ -532,7 +529,7 @@ public P removeFinalizer(String finalizerName) { if (!resource.hasFinalizer(finalizerName)) { return resource; } - return conflictRetryingPatch( + return conflictRetryingPatchPrimary( r -> { r.removeFinalizer(finalizerName); return r; @@ -555,11 +552,10 @@ public P removeFinalizer(String finalizerName) { * @param preCondition condition to check if the patch operation still needs to be performed or * not. * @return updated resource from the server or unchanged if the precondition does not hold. - * @param resource type */ @SuppressWarnings("unchecked") - public R conflictRetryingPatch( - UnaryOperator resourceChangesOperator, Predicate preCondition) { + public P conflictRetryingPatchPrimary( + UnaryOperator

resourceChangesOperator, Predicate

preCondition) { var resource = context.getPrimaryResource(); var client = context.getClient(); if (log.isDebugEnabled()) { @@ -568,10 +564,10 @@ public R conflictRetryingPatch( int retryIndex = 0; while (true) { try { - if (!preCondition.test((R) resource)) { - return (R) resource; + if (!preCondition.test(resource)) { + return resource; } - return jsonPatchPrimary((R) resource, resourceChangesOperator); + return jsonPatchPrimary(resource, resourceChangesOperator); } catch (KubernetesClientException e) { log.trace("Exception during patch for resource: {}", resource); retryIndex++; @@ -642,11 +638,9 @@ public P addFinalizerWithSSA(String finalizerName) { getVersion(originalResource)); } try { + @SuppressWarnings("unchecked") 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 serverSideApplyPrimary(resource);