@@ -77,7 +77,7 @@
io.javaoperatorsdk
- operator-framework-junit-5
+ operator-framework-junit
${project.version}
diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml
index bb44f0dedc..361bb3ad53 100644
--- a/operator-framework-core/pom.xml
+++ b/operator-framework-core/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdk
java-operator-sdk
- 5.2.3-SNAPSHOT
+ 5.3.0-SNAPSHOT
../pom.xml
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java
index 5adc90182d..0cfe0e997a 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java
@@ -263,7 +263,7 @@ public RegisteredController
register(
"Cannot register reconciler with name "
+ reconciler.getClass().getCanonicalName()
+ " reconciler named "
- + ReconcilerUtils.getNameFor(reconciler)
+ + ReconcilerUtilsInternal.getNameFor(reconciler)
+ " because its configuration cannot be found.\n"
+ " Known reconcilers are: "
+ configurationService.getKnownReconcilerNames());
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java
similarity index 64%
rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java
rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java
index 354c2aa420..26ae5af554 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java
@@ -31,10 +31,11 @@
import io.fabric8.kubernetes.client.utils.Serialization;
import io.javaoperatorsdk.operator.api.reconciler.Constants;
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.NonComparableResourceVersionException;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
@SuppressWarnings("rawtypes")
-public class ReconcilerUtils {
+public class ReconcilerUtilsInternal {
private static final String FINALIZER_NAME_SUFFIX = "/finalizer";
protected static final String MISSING_GROUP_SUFFIX = ".javaoperatorsdk.io";
@@ -46,7 +47,7 @@ public class ReconcilerUtils {
Pattern.compile(".*http(s?)://[^/]*/api(s?)/(\\S*).*"); // NOSONAR: input is controlled
// prevent instantiation of util class
- private ReconcilerUtils() {}
+ private ReconcilerUtilsInternal() {}
public static boolean isFinalizerValid(String finalizer) {
return HasMetadata.validateFinalizer(finalizer);
@@ -241,4 +242,123 @@ private static boolean matchesResourceType(
}
return false;
}
+
+ /**
+ * Compares resource versions of two resources. This is a convenience method that extracts the
+ * resource versions from the metadata and delegates to {@link
+ * #validateAndCompareResourceVersions(String, String)}.
+ *
+ * @param h1 first resource
+ * @param h2 second resource
+ * @return negative if h1 is older, zero if equal, positive if h1 is newer
+ * @throws NonComparableResourceVersionException if either resource version is invalid
+ */
+ public static int validateAndCompareResourceVersions(HasMetadata h1, HasMetadata h2) {
+ return validateAndCompareResourceVersions(
+ h1.getMetadata().getResourceVersion(), h2.getMetadata().getResourceVersion());
+ }
+
+ /**
+ * Compares the resource versions of two Kubernetes resources.
+ *
+ *
This method extracts the resource versions from the metadata of both resources and delegates
+ * to {@link #compareResourceVersions(String, String)} for the actual comparison.
+ *
+ * @param h1 the first resource to compare
+ * @param h2 the second resource to compare
+ * @return a negative integer if h1's version is less than h2's version, zero if they are equal,
+ * or a positive integer if h1's version is greater than h2's version
+ * @see #compareResourceVersions(String, String)
+ */
+ public static int compareResourceVersions(HasMetadata h1, HasMetadata h2) {
+ return compareResourceVersions(
+ h1.getMetadata().getResourceVersion(), h2.getMetadata().getResourceVersion());
+ }
+
+ /**
+ * Compares two resource version strings using a length-first, then lexicographic comparison
+ * algorithm.
+ *
+ *
The comparison is performed in two steps:
+ *
+ *
+ * - First, compare the lengths of the version strings. A longer version string is considered
+ * greater than a shorter one. This works correctly for numeric versions because larger
+ * numbers have more digits (e.g., "100" > "99").
+ *
- If the lengths are equal, perform a character-by-character lexicographic comparison until
+ * a difference is found.
+ *
+ *
+ * This algorithm is more efficient than parsing the versions as numbers, especially for
+ * Kubernetes resource versions which are typically monotonically increasing numeric strings.
+ *
+ *
Note: This method does not validate that the input strings are numeric. For
+ * validated numeric comparison, use {@link #validateAndCompareResourceVersions(String, String)}.
+ *
+ * @param v1 the first resource version string
+ * @param v2 the second resource version string
+ * @return a negative integer if v1 is less than v2, zero if they are equal, or a positive integer
+ * if v1 is greater than v2
+ * @see #validateAndCompareResourceVersions(String, String)
+ */
+ public static int compareResourceVersions(String v1, String v2) {
+ int comparison = v1.length() - v2.length();
+ if (comparison != 0) {
+ return comparison;
+ }
+ for (int i = 0; i < v2.length(); i++) {
+ int comp = v1.charAt(i) - v2.charAt(i);
+ if (comp != 0) {
+ return comp;
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Compares two Kubernetes resource versions numerically. Kubernetes resource versions are
+ * expected to be numeric strings that increase monotonically. This method assumes both versions
+ * are valid numeric strings without leading zeros.
+ *
+ * @param v1 first resource version
+ * @param v2 second resource version
+ * @return negative if v1 is older, zero if equal, positive if v1 is newer
+ * @throws NonComparableResourceVersionException if either resource version is empty, has leading
+ * zeros, or contains non-numeric characters
+ */
+ public static int validateAndCompareResourceVersions(String v1, String v2) {
+ int v1Length = validateResourceVersion(v1);
+ int v2Length = validateResourceVersion(v2);
+ int comparison = v1Length - v2Length;
+ if (comparison != 0) {
+ return comparison;
+ }
+ for (int i = 0; i < v2Length; i++) {
+ int comp = v1.charAt(i) - v2.charAt(i);
+ if (comp != 0) {
+ return comp;
+ }
+ }
+ return 0;
+ }
+
+ private static int validateResourceVersion(String v1) {
+ int v1Length = v1.length();
+ if (v1Length == 0) {
+ throw new NonComparableResourceVersionException("Resource version is empty");
+ }
+ for (int i = 0; i < v1Length; i++) {
+ char char1 = v1.charAt(i);
+ if (char1 == '0') {
+ if (i == 0) {
+ throw new NonComparableResourceVersionException(
+ "Resource version cannot begin with 0: " + v1);
+ }
+ } else if (char1 < '0' || char1 > '9') {
+ throw new NonComparableResourceVersionException(
+ "Non numeric characters in resource version: " + v1);
+ }
+ }
+ return v1Length;
+ }
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java
index b85ee03fcb..a1b37d6fe9 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java
@@ -22,7 +22,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.KubernetesClient;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
/**
@@ -145,7 +145,7 @@ private String getReconcilersNameMessage() {
}
protected String keyFor(Reconciler reconciler) {
- return ReconcilerUtils.getNameFor(reconciler);
+ return ReconcilerUtilsInternal.getNameFor(reconciler);
}
@SuppressWarnings("unused")
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java
index 0a7d3ece04..6b7579b6a8 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java
@@ -28,7 +28,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.KubernetesClient;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.Utils.Configurator;
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver;
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
@@ -265,7 +265,7 @@ private ResolvedControllerConfiguration
controllerCon
io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration annotation) {
final var resourceClass = getResourceClassResolver().getPrimaryResourceClass(reconcilerClass);
- final var name = ReconcilerUtils.getNameFor(reconcilerClass);
+ final var name = ReconcilerUtilsInternal.getNameFor(reconcilerClass);
final var generationAware =
valueOrDefaultFromAnnotation(
annotation,
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java
index 6215c20179..6ed9b7ff64 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java
@@ -28,8 +28,6 @@
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Secret;
-import io.fabric8.kubernetes.api.model.apps.Deployment;
-import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.client.Config;
import io.fabric8.kubernetes.client.ConfigBuilder;
import io.fabric8.kubernetes.client.CustomResource;
@@ -447,64 +445,6 @@ default Set> defaultNonSSAResource() {
return defaultNonSSAResources();
}
- /**
- * If a javaoperatorsdk.io/previous annotation should be used so that the operator sdk can detect
- * events from its own updates of dependent resources and then filter them.
- *
- * Disable this if you want to react to your own dependent resource updates
- *
- * @return if special annotation should be used for dependent resource to filter events
- * @since 4.5.0
- */
- default boolean previousAnnotationForDependentResourcesEventFiltering() {
- return true;
- }
-
- /**
- * For dependent resources, the framework can add an annotation to filter out events resulting
- * directly from the framework's operation. There are, however, some resources that do not follow
- * the Kubernetes API conventions that changes in metadata should not increase the generation of
- * the resource (as recorded in the {@code generation} field of the resource's {@code metadata}).
- * For these resources, this convention is not respected and results in a new event for the
- * framework to process. If that particular case is not handled correctly in the resource matcher,
- * the framework will consider that the resource doesn't match the desired state and therefore
- * triggers an update, which in turn, will re-add the annotation, thus starting the loop again,
- * infinitely.
- *
- *
As a workaround, we automatically skip adding previous annotation for those well-known
- * resources. Note that if you are sure that the matcher works for your use case, and it should in
- * most instances, you can remove the resource type from the blocklist.
- *
- *
The consequence of adding a resource type to the set is that the framework will not use
- * event filtering to prevent events, initiated by changes made by the framework itself as a
- * result of its processing of dependent resources, to trigger the associated reconciler again.
- *
- *
Note that this method only takes effect if annotating dependent resources to prevent
- * dependent resources events from triggering the associated reconciler again is activated as
- * controlled by {@link #previousAnnotationForDependentResourcesEventFiltering()}
- *
- * @return a Set of resource classes where the previous version annotation won't be used.
- */
- default Set> withPreviousAnnotationForDependentResourcesBlocklist() {
- return Set.of(Deployment.class, StatefulSet.class);
- }
-
- /**
- * If the event logic should parse the resourceVersion to determine the ordering of dependent
- * resource events. This is typically not needed.
- *
- * Disabled by default as Kubernetes does not support, and discourages, this interpretation of
- * resourceVersions. Enable only if your api server event processing seems to lag the operator
- * logic, and you want to further minimize the amount of work done / updates issued by the
- * operator.
- *
- * @return if resource version should be parsed (as integer)
- * @since 4.5.0
- */
- default boolean parseResourceVersionsForEventFilteringAndCaching() {
- return false;
- }
-
/**
* {@link io.javaoperatorsdk.operator.api.reconciler.UpdateControl} patch resource or status can
* either use simple patches or SSA. Setting this to {@code true}, controllers will use SSA for
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java
index 3d29bb6589..cd9cdafb39 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java
@@ -51,11 +51,8 @@ public class ConfigurationServiceOverrider {
private Duration reconciliationTerminationTimeout;
private Boolean ssaBasedCreateUpdateMatchForDependentResources;
private Set> defaultNonSSAResource;
- private Boolean previousAnnotationForDependentResources;
- private Boolean parseResourceVersions;
private Boolean useSSAToPatchPrimaryResource;
private Boolean cloneSecondaryResourcesWhenGettingFromCache;
- private Set> previousAnnotationUsageBlocklist;
@SuppressWarnings("rawtypes")
private DependentResourceFactory dependentResourceFactory;
@@ -168,31 +165,6 @@ public ConfigurationServiceOverrider withDefaultNonSSAResource(
return this;
}
- public ConfigurationServiceOverrider withPreviousAnnotationForDependentResources(boolean value) {
- this.previousAnnotationForDependentResources = value;
- return this;
- }
-
- /**
- * @param value true if internal algorithms can use metadata.resourceVersion as a numeric value.
- * @return this
- */
- public ConfigurationServiceOverrider withParseResourceVersions(boolean value) {
- this.parseResourceVersions = value;
- return this;
- }
-
- /**
- * @deprecated use withParseResourceVersions
- * @param value true if internal algorithms can use metadata.resourceVersion as a numeric value.
- * @return this
- */
- @Deprecated(forRemoval = true)
- public ConfigurationServiceOverrider wihtParseResourceVersions(boolean value) {
- this.parseResourceVersions = value;
- return this;
- }
-
public ConfigurationServiceOverrider withUseSSAToPatchPrimaryResource(boolean value) {
this.useSSAToPatchPrimaryResource = value;
return this;
@@ -204,12 +176,6 @@ public ConfigurationServiceOverrider withCloneSecondaryResourcesWhenGettingFromC
return this;
}
- public ConfigurationServiceOverrider withPreviousAnnotationForDependentResourcesBlocklist(
- Set> blocklist) {
- this.previousAnnotationUsageBlocklist = blocklist;
- return this;
- }
-
public ConfigurationService build() {
return new BaseConfigurationService(original.getVersion(), cloner, client) {
@Override
@@ -331,20 +297,6 @@ public Set> defaultNonSSAResources() {
defaultNonSSAResource, ConfigurationService::defaultNonSSAResources);
}
- @Override
- public boolean previousAnnotationForDependentResourcesEventFiltering() {
- return overriddenValueOrDefault(
- previousAnnotationForDependentResources,
- ConfigurationService::previousAnnotationForDependentResourcesEventFiltering);
- }
-
- @Override
- public boolean parseResourceVersionsForEventFilteringAndCaching() {
- return overriddenValueOrDefault(
- parseResourceVersions,
- ConfigurationService::parseResourceVersionsForEventFilteringAndCaching);
- }
-
@Override
public boolean useSSAToPatchPrimaryResource() {
return overriddenValueOrDefault(
@@ -357,14 +309,6 @@ public boolean cloneSecondaryResourcesWhenGettingFromCache() {
cloneSecondaryResourcesWhenGettingFromCache,
ConfigurationService::cloneSecondaryResourcesWhenGettingFromCache);
}
-
- @Override
- public Set>
- withPreviousAnnotationForDependentResourcesBlocklist() {
- return overriddenValueOrDefault(
- previousAnnotationUsageBlocklist,
- ConfigurationService::withPreviousAnnotationForDependentResourcesBlocklist);
- }
};
}
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java
index 8bddc8479e..63177b614f 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java
@@ -20,7 +20,7 @@
import java.util.Set;
import io.fabric8.kubernetes.api.model.HasMetadata;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec;
import io.javaoperatorsdk.operator.api.reconciler.MaxReconciliationInterval;
@@ -42,16 +42,18 @@ default String getName() {
}
default String getFinalizerName() {
- return ReconcilerUtils.getDefaultFinalizerName(getResourceClass());
+ return ReconcilerUtilsInternal.getDefaultFinalizerName(getResourceClass());
}
static String ensureValidName(String name, String reconcilerClassName) {
- return name != null ? name : ReconcilerUtils.getDefaultReconcilerName(reconcilerClassName);
+ return name != null
+ ? name
+ : ReconcilerUtilsInternal.getDefaultReconcilerName(reconcilerClassName);
}
static String ensureValidFinalizerName(String finalizer, String resourceTypeName) {
if (finalizer != null && !finalizer.isBlank()) {
- if (ReconcilerUtils.isFinalizerValid(finalizer)) {
+ if (ReconcilerUtilsInternal.isFinalizerValid(finalizer)) {
return finalizer;
} else {
throw new IllegalArgumentException(
@@ -61,7 +63,7 @@ static String ensureValidFinalizerName(String finalizer, String resourceTypeName
+ " for details");
}
} else {
- return ReconcilerUtils.getDefaultFinalizerName(resourceTypeName);
+ return ReconcilerUtilsInternal.getDefaultFinalizerName(resourceTypeName);
}
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java
index 9264db66bc..e6655641a2 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java
@@ -28,6 +28,7 @@
import io.javaoperatorsdk.operator.processing.event.source.filter.OnDeleteFilter;
import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter;
+import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_FOLLOW_CONTROLLER_NAMESPACE_CHANGES;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_LONG_VALUE_SET;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_VALUE_SET;
@@ -131,4 +132,11 @@
/** Kubernetes field selector for additional resource filtering */
Field[] fieldSelector() default {};
+
+ /**
+ * true if we can consider resource versions as integers, therefore it is valid to compare them
+ *
+ * @since 5.3.0
+ */
+ boolean comparableResourceVersions() default DEFAULT_COMPARABLE_RESOURCE_VERSION;
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java
index 24f78eb7be..f6caa4fe4d 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java
@@ -25,7 +25,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.informers.cache.ItemStore;
import io.javaoperatorsdk.operator.OperatorException;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.Utils;
import io.javaoperatorsdk.operator.api.reconciler.Constants;
@@ -53,6 +53,7 @@ public class InformerConfiguration {
private ItemStore itemStore;
private Long informerListLimit;
private FieldSelector fieldSelector;
+ private boolean comparableResourceVersions;
protected InformerConfiguration(
Class resourceClass,
@@ -66,7 +67,8 @@ protected InformerConfiguration(
GenericFilter super R> genericFilter,
ItemStore itemStore,
Long informerListLimit,
- FieldSelector fieldSelector) {
+ FieldSelector fieldSelector,
+ boolean comparableResourceVersions) {
this(resourceClass);
this.name = name;
this.namespaces = namespaces;
@@ -79,6 +81,7 @@ protected InformerConfiguration(
this.itemStore = itemStore;
this.informerListLimit = informerListLimit;
this.fieldSelector = fieldSelector;
+ this.comparableResourceVersions = comparableResourceVersions;
}
private InformerConfiguration(Class resourceClass) {
@@ -89,7 +92,7 @@ private InformerConfiguration(Class resourceClass) {
// controller
// where GenericKubernetesResource now does not apply
? GenericKubernetesResource.class.getSimpleName()
- : ReconcilerUtils.getResourceTypeName(resourceClass);
+ : ReconcilerUtilsInternal.getResourceTypeName(resourceClass);
}
@SuppressWarnings({"rawtypes", "unchecked"})
@@ -113,7 +116,8 @@ public static InformerConfiguration.Builder builder(
original.genericFilter,
original.itemStore,
original.informerListLimit,
- original.fieldSelector)
+ original.fieldSelector,
+ original.comparableResourceVersions)
.builder;
}
@@ -288,6 +292,10 @@ public FieldSelector getFieldSelector() {
return fieldSelector;
}
+ public boolean isComparableResourceVersions() {
+ return comparableResourceVersions;
+ }
+
@SuppressWarnings("UnusedReturnValue")
public class Builder {
@@ -359,6 +367,7 @@ public InformerConfiguration.Builder initFromAnnotation(
Arrays.stream(informerConfig.fieldSelector())
.map(f -> new FieldSelector.Field(f.path(), f.value(), f.negated()))
.toList()));
+ withComparableResourceVersions(informerConfig.comparableResourceVersions());
}
return this;
}
@@ -459,5 +468,10 @@ public Builder withFieldSelector(FieldSelector fieldSelector) {
InformerConfiguration.this.fieldSelector = fieldSelector;
return this;
}
+
+ public Builder withComparableResourceVersions(boolean comparableResourceVersions) {
+ InformerConfiguration.this.comparableResourceVersions = comparableResourceVersions;
+ return this;
+ }
}
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java
index bca605a41c..69903e805f 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java
@@ -33,6 +33,7 @@
import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter;
import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers;
+import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.SAME_AS_CONTROLLER_NAMESPACES_SET;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACE_SET;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_CURRENT_NAMESPACE_SET;
@@ -96,18 +97,21 @@ class DefaultInformerEventSourceConfiguration
private final GroupVersionKind groupVersionKind;
private final InformerConfiguration informerConfig;
private final KubernetesClient kubernetesClient;
+ private final boolean comparableResourceVersion;
protected DefaultInformerEventSourceConfiguration(
GroupVersionKind groupVersionKind,
PrimaryToSecondaryMapper> primaryToSecondaryMapper,
SecondaryToPrimaryMapper secondaryToPrimaryMapper,
InformerConfiguration informerConfig,
- KubernetesClient kubernetesClient) {
+ KubernetesClient kubernetesClient,
+ boolean comparableResourceVersion) {
this.informerConfig = Objects.requireNonNull(informerConfig);
this.groupVersionKind = groupVersionKind;
this.primaryToSecondaryMapper = primaryToSecondaryMapper;
this.secondaryToPrimaryMapper = secondaryToPrimaryMapper;
this.kubernetesClient = kubernetesClient;
+ this.comparableResourceVersion = comparableResourceVersion;
}
@Override
@@ -135,6 +139,11 @@ public Optional getGroupVersionKind() {
public Optional getKubernetesClient() {
return Optional.ofNullable(kubernetesClient);
}
+
+ @Override
+ public boolean comparableResourceVersion() {
+ return this.comparableResourceVersion;
+ }
}
@SuppressWarnings({"unused", "UnusedReturnValue"})
@@ -148,6 +157,7 @@ class Builder {
private PrimaryToSecondaryMapper> primaryToSecondaryMapper;
private SecondaryToPrimaryMapper secondaryToPrimaryMapper;
private KubernetesClient kubernetesClient;
+ private boolean comparableResourceVersion = DEFAULT_COMPARABLE_RESOURCE_VERSION;
private Builder(Class resourceClass, Class extends HasMetadata> primaryResourceClass) {
this(resourceClass, primaryResourceClass, null);
@@ -285,6 +295,11 @@ public Builder withFieldSelector(FieldSelector fieldSelector) {
return this;
}
+ public Builder withComparableResourceVersion(boolean comparableResourceVersion) {
+ this.comparableResourceVersion = comparableResourceVersion;
+ return this;
+ }
+
public void updateFrom(InformerConfiguration informerConfig) {
if (informerConfig != null) {
final var informerConfigName = informerConfig.getName();
@@ -324,7 +339,10 @@ public InformerEventSourceConfiguration build() {
HasMetadata.getKind(primaryResourceClass),
false)),
config.build(),
- kubernetesClient);
+ kubernetesClient,
+ comparableResourceVersion);
}
}
+
+ boolean comparableResourceVersion();
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java
index 5087f4052a..6ac46ee0a6 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java
@@ -21,22 +21,53 @@
public abstract class BaseControl> {
+ public static final Long INSTANT_RESCHEDULE = 0L;
+
private Long scheduleDelay = null;
+ /**
+ * Schedules a reconciliation to occur after the specified delay in milliseconds.
+ *
+ * @param delay the delay in milliseconds after which to reschedule
+ * @return this control instance for fluent chaining
+ */
public T rescheduleAfter(long delay) {
rescheduleAfter(Duration.ofMillis(delay));
return (T) this;
}
+ /**
+ * Schedules a reconciliation to occur after the specified delay.
+ *
+ * @param delay the {@link Duration} after which to reschedule
+ * @return this control instance for fluent chaining
+ */
public T rescheduleAfter(Duration delay) {
this.scheduleDelay = delay.toMillis();
return (T) this;
}
+ /**
+ * Schedules a reconciliation to occur after the specified delay using the given time unit.
+ *
+ * @param delay the delay value
+ * @param timeUnit the time unit of the delay
+ * @return this control instance for fluent chaining
+ */
public T rescheduleAfter(long delay, TimeUnit timeUnit) {
return rescheduleAfter(timeUnit.toMillis(delay));
}
+ /**
+ * Schedules an instant reconciliation. The reconciliation will be triggered as soon as possible.
+ *
+ * @return this control instance for fluent chaining
+ */
+ public T reschedule() {
+ this.scheduleDelay = INSTANT_RESCHEDULE;
+ return (T) this;
+ }
+
public Optional getScheduleDelay() {
return Optional.ofNullable(scheduleDelay);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java
index 052b4d8c44..7330a407c1 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java
@@ -41,6 +41,7 @@ public final class Constants {
public static final String RESOURCE_GVK_KEY = "josdk.resource.gvk";
public static final String CONTROLLER_NAME = "controller.name";
public static final boolean DEFAULT_FOLLOW_CONTROLLER_NAMESPACE_CHANGES = true;
+ public static final boolean DEFAULT_COMPARABLE_RESOURCE_VERSION = true;
private Constants() {}
}
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..2df74d4298 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
@@ -35,12 +35,83 @@ default Optional getSecondaryResource(Class expectedType) {
return getSecondaryResource(expectedType, null);
}
- Set getSecondaryResources(Class expectedType);
+ /**
+ * Retrieves a {@link Set} of the secondary resources of the specified type, which are associated
+ * with the primary resource being processed, possibly making sure that only the latest version of
+ * each resource is retrieved.
+ *
+ * Note: While this method returns a {@link Set}, it is possible to get several copies of a
+ * given resource albeit all with different {@code resourceVersion}. If you want to avoid this
+ * situation, call {@link #getSecondaryResources(Class, boolean)} with the {@code deduplicate}
+ * parameter set to {@code true}.
+ *
+ * @param expectedType a class representing the type of secondary resources to retrieve
+ * @param the type of secondary resources to retrieve
+ * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated
+ */
+ default Set getSecondaryResources(Class expectedType) {
+ return getSecondaryResources(expectedType, false);
+ }
+
+ /**
+ * Retrieves a {@link Set} of the secondary resources of the specified type, which are associated
+ * with the primary resource being processed, possibly making sure that only the latest version of
+ * each resource is retrieved.
+ *
+ * Note: While this method returns a {@link Set}, it is possible to get several copies of a
+ * given resource albeit all with different {@code resourceVersion}. If you want to avoid this
+ * situation, ask for the deduplicated version by setting the {@code deduplicate} parameter to
+ * {@code true}.
+ *
+ * @param expectedType a class representing the type of secondary resources to retrieve
+ * @param deduplicate {@code true} if only the latest version of each resource should be kept,
+ * {@code false} otherwise
+ * @param the type of secondary resources to retrieve
+ * @return a {@link Set} of secondary resources of the specified type, possibly deduplicated
+ * @throws IllegalArgumentException if the secondary resource type cannot be deduplicated because
+ * it's not extending {@link HasMetadata}, which is required to access the resource version
+ * @since 5.3.0
+ */
+ Set getSecondaryResources(Class expectedType, boolean deduplicate);
+ /**
+ * Retrieves a {@link Stream} of the secondary resources of the specified type, which are
+ * associated with the primary resource being processed, possibly making sure that only the latest
+ * version of each resource is retrieved.
+ *
+ * Note: It is possible to get several copies of a given resource albeit all with different
+ * {@code resourceVersion}. If you want to avoid this situation, call {@link
+ * #getSecondaryResourcesAsStream(Class, boolean)} with the {@code deduplicate} parameter set to
+ * {@code true}.
+ *
+ * @param expectedType a class representing the type of secondary resources to retrieve
+ * @param the type of secondary resources to retrieve
+ * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated
+ */
default Stream getSecondaryResourcesAsStream(Class expectedType) {
- return getSecondaryResources(expectedType).stream();
+ return getSecondaryResourcesAsStream(expectedType, false);
}
+ /**
+ * Retrieves a {@link Stream} of the secondary resources of the specified type, which are
+ * associated with the primary resource being processed, possibly making sure that only the latest
+ * version of each resource is retrieved.
+ *
+ * Note: It is possible to get several copies of a given resource albeit all with different
+ * {@code resourceVersion}. If you want to avoid this situation, ask for the deduplicated version
+ * by setting the {@code deduplicate} parameter to {@code true}.
+ *
+ * @param expectedType a class representing the type of secondary resources to retrieve
+ * @param deduplicate {@code true} if only the latest version of each resource should be kept,
+ * {@code false} otherwise
+ * @param the type of secondary resources to retrieve
+ * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated
+ * @throws IllegalArgumentException if the secondary resource type cannot be deduplicated because
+ * it's not extending {@link HasMetadata}, which is required to access the resource version
+ * @since 5.3.0
+ */
+ Stream getSecondaryResourcesAsStream(Class expectedType, boolean deduplicate);
+
Optional getSecondaryResource(Class expectedType, String eventSourceName);
ControllerConfiguration getControllerConfiguration();
@@ -58,6 +129,8 @@ default Stream getSecondaryResourcesAsStream(Class expectedType) {
KubernetesClient getClient();
+ 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 f3fade4659..ac5a7b41b9 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
@@ -15,15 +15,21 @@
*/
package io.javaoperatorsdk.operator.api.reconciler;
+import java.util.HashSet;
+import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
+import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.KubernetesClient;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext;
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext;
import io.javaoperatorsdk.operator.processing.Controller;
@@ -32,7 +38,6 @@
import io.javaoperatorsdk.operator.processing.event.ResourceID;
public class DefaultContext
implements Context
{
-
private RetryInfo retryInfo;
private final Controller
controller;
private final P primaryResource;
@@ -41,6 +46,8 @@ public class DefaultContext
implements Context
{
defaultManagedDependentResourceContext;
private final boolean primaryResourceDeleted;
private final boolean primaryResourceFinalStateUnknown;
+ private final Map, Object> desiredStates = new ConcurrentHashMap<>();
+ private final ResourceOperations resourceOperations;
public DefaultContext(
RetryInfo retryInfo,
@@ -56,6 +63,7 @@ public DefaultContext(
this.primaryResourceFinalStateUnknown = primaryResourceFinalStateUnknown;
this.defaultManagedDependentResourceContext =
new DefaultManagedWorkflowAndDependentResourceContext<>(controller, primaryResource, this);
+ this.resourceOperations = new ResourceOperations<>(this);
}
@Override
@@ -64,15 +72,44 @@ public Optional getRetryInfo() {
}
@Override
- public Set getSecondaryResources(Class expectedType) {
+ public Set getSecondaryResources(Class expectedType, boolean deduplicate) {
+ if (deduplicate) {
+ final var deduplicatedMap = deduplicatedMap(getSecondaryResourcesAsStream(expectedType));
+ return new HashSet<>(deduplicatedMap.values());
+ }
return getSecondaryResourcesAsStream(expectedType).collect(Collectors.toSet());
}
- @Override
- public Stream getSecondaryResourcesAsStream(Class expectedType) {
- return controller.getEventSourceManager().getEventSourcesFor(expectedType).stream()
- .map(es -> es.getSecondaryResources(primaryResource))
- .flatMap(Set::stream);
+ public Stream getSecondaryResourcesAsStream(Class expectedType, boolean deduplicate) {
+ final var stream =
+ controller.getEventSourceManager().getEventSourcesFor(expectedType).stream()
+ .mapMulti(
+ (es, consumer) -> es.getSecondaryResources(primaryResource).forEach(consumer));
+ if (deduplicate) {
+ if (!HasMetadata.class.isAssignableFrom(expectedType)) {
+ throw new IllegalArgumentException("Can only de-duplicate HasMetadata descendants");
+ }
+ return deduplicatedMap(stream).values().stream();
+ } else {
+ return stream;
+ }
+ }
+
+ private Map deduplicatedMap(Stream stream) {
+ return stream.collect(
+ Collectors.toUnmodifiableMap(
+ DefaultContext::resourceID,
+ Function.identity(),
+ (existing, replacement) ->
+ compareResourceVersions(existing, replacement) >= 0 ? existing : replacement));
+ }
+
+ private static ResourceID resourceID(Object hasMetadata) {
+ return ResourceID.fromResource((HasMetadata) hasMetadata);
+ }
+
+ private static int compareResourceVersions(Object v1, Object v2) {
+ return ReconcilerUtilsInternal.compareResourceVersions((HasMetadata) v1, (HasMetadata) v2);
}
@Override
@@ -119,6 +156,11 @@ public KubernetesClient getClient() {
return controller.getClient();
}
+ @Override
+ public ResourceOperations resourceOperations() {
+ return resourceOperations;
+ }
+
@Override
public ExecutorService getWorkflowExecutorService() {
// note that this should be always received from executor service manager, so we are able to do
@@ -157,4 +199,12 @@ public DefaultContext
setRetryInfo(RetryInfo retryInfo) {
this.retryInfo = retryInfo;
return this;
}
+
+ @SuppressWarnings("unchecked")
+ public R getOrComputeDesiredStateFor(
+ DependentResource dependentResource, Function desiredStateComputer) {
+ return (R)
+ desiredStates.computeIfAbsent(
+ dependentResource, ignored -> desiredStateComputer.apply(getPrimaryResource()));
+ }
}
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 6103b4b12b..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
@@ -45,7 +45,11 @@
* caches the updated resource from the response in an overlay cache on top of the Informer cache.
* If the update fails, it reads the primary resource from the cluster, applies the modifications
* again and retries the update.
+ *
+ * @deprecated Use {@link Context#resourceOperations()} that contains the more efficient up-to-date
+ * versions of methods.
*/
+@Deprecated(forRemoval = true)
public class PrimaryUpdateAndCacheUtils {
public static final int DEFAULT_MAX_RETRY = 10;
@@ -450,4 +454,45 @@ public static
P addFinalizerWithSSA(
e);
}
}
+
+ public static int compareResourceVersions(HasMetadata h1, HasMetadata h2) {
+ return compareResourceVersions(
+ h1.getMetadata().getResourceVersion(), h2.getMetadata().getResourceVersion());
+ }
+
+ public static int compareResourceVersions(String v1, String v2) {
+ int v1Length = validateResourceVersion(v1);
+ int v2Length = validateResourceVersion(v2);
+ int comparison = v1Length - v2Length;
+ if (comparison != 0) {
+ return comparison;
+ }
+ for (int i = 0; i < v2Length; i++) {
+ int comp = v1.charAt(i) - v2.charAt(i);
+ if (comp != 0) {
+ return comp;
+ }
+ }
+ return 0;
+ }
+
+ private static int validateResourceVersion(String v1) {
+ int v1Length = v1.length();
+ if (v1Length == 0) {
+ throw new NonComparableResourceVersionException("Resource version is empty");
+ }
+ for (int i = 0; i < v1Length; i++) {
+ char char1 = v1.charAt(i);
+ if (char1 == '0') {
+ if (i == 0) {
+ throw new NonComparableResourceVersionException(
+ "Resource version cannot begin with 0: " + v1);
+ }
+ } else if (char1 < '0' || char1 > '9') {
+ throw new NonComparableResourceVersionException(
+ "Non numeric characters in resource version: " + v1);
+ }
+ }
+ return v1Length;
+ }
}
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
new file mode 100644
index 0000000000..de4d00d717
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java
@@ -0,0 +1,756 @@
+/*
+ * 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;
+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.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.InformerEventSource;
+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;
+
+/**
+ * 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;
+
+ private static final Logger log = LoggerFactory.getLogger(ResourceOperations.class);
+
+ private final Context
context;
+
+ public ResourceOperations(Context
context) {
+ this.context = context;
+ }
+
+ /**
+ * 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 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.
+ *
+ * @param resource fresh resource for server side apply
+ * @return updated resource
+ * @param resource type
+ */
+ public R serverSideApply(R 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()));
+ }
+
+ /**
+ * 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 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.
+ *
+ * @param resource fresh resource for server side apply
+ * @return updated resource
+ * @param informerEventSource InformerEventSource to use for resource caching and filtering
+ * @param resource type
+ */
+ public R serverSideApply(
+ R resource, InformerEventSource informerEventSource) {
+ if (informerEventSource == null) {
+ return serverSideApply(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()),
+ informerEventSource);
+ }
+
+ /**
+ * 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
+ * @param resource type
+ */
+ public R serverSideApplyStatus(R 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()));
+ }
+
+ /**
+ * 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
+ */
+ 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 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
+ */
+ 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 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
+ * @param resource type
+ */
+ public R update(R resource) {
+ return resourcePatch(resource, r -> context.getClient().resource(r).update());
+ }
+
+ /**
+ * 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
+ * @param informerEventSource InformerEventSource to use for resource caching and filtering
+ * @param resource type
+ */
+ public R update(
+ R resource, InformerEventSource informerEventSource) {
+ if (informerEventSource == null) {
+ return update(resource);
+ }
+ return resourcePatch(
+ resource, r -> context.getClient().resource(r).update(), informerEventSource);
+ }
+
+ /**
+ * Creates 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
+ * @param resource type
+ */
+ public R create(R resource) {
+ return resourcePatch(resource, r -> context.getClient().resource(r).create());
+ }
+
+ /**
+ * Creates 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
+ * @param informerEventSource InformerEventSource to use for resource caching and filtering
+ * @param resource type
+ */
+ public R create(
+ R resource, InformerEventSource informerEventSource) {
+ if (informerEventSource == null) {
+ return create(resource);
+ }
+ return resourcePatch(
+ resource, r -> context.getClient().resource(r).create(), informerEventSource);
+ }
+
+ /**
+ * 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
+ * @param resource type
+ */
+ public R updateStatus(R resource) {
+ return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus());
+ }
+
+ /**
+ * 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
+ */
+ public P updatePrimary(P resource) {
+ return resourcePatch(
+ resource,
+ r -> context.getClient().resource(r).update(),
+ context.eventSourceRetriever().getControllerEventSource());
+ }
+
+ /**
+ * 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
+ */
+ public P updatePrimaryStatus(P 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.
+ *
+ *
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
+ * @param resource type
+ */
+ public R jsonPatch(R resource, UnaryOperator unaryOperator) {
+ return resourcePatch(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.
+ *
+ * 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
+ * @param resource type
+ */
+ public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) {
+ return resourcePatch(resource, r -> context.getClient().resource(r).editStatus(unaryOperator));
+ }
+
+ /**
+ * 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
+ * @return updated resource
+ */
+ public P jsonPatchPrimary(P 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.
+ *
+ *
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
+ * @return updated resource
+ */
+ public P jsonPatchPrimaryStatus(P 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.
+ *
+ *
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
+ */
+ public R jsonMergePatch(R resource) {
+ return resourcePatch(resource, r -> context.getClient().resource(r).patch());
+ }
+
+ /**
+ * 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
+ * @param resource type
+ */
+ public R jsonMergePatchStatus(R resource) {
+ return resourcePatch(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.
+ *
+ * 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
+ */
+ public P jsonMergePatchPrimary(P resource) {
+ return resourcePatch(
+ resource,
+ r -> context.getClient().resource(r).patch(),
+ context.eventSourceRetriever().getControllerEventSource());
+ }
+
+ /**
+ * 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
+ * @see #jsonMergePatchPrimaryStatus(HasMetadata)
+ */
+ public P jsonMergePatchPrimaryStatus(P resource) {
+ return resourcePatch(
+ resource,
+ r -> context.getClient().resource(r).patchStatus(),
+ context.eventSourceRetriever().getControllerEventSource());
+ }
+
+ /**
+ * 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 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
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public R resourcePatch(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());
+ }
+ var es = esList.get(0);
+ if (esList.size() > 1) {
+ log.warn(
+ "Multiple event sources found for type: {}, selecting first with name {}",
+ resource.getClass(),
+ es.name());
+ }
+ if (es instanceof ManagedInformerEventSource mes) {
+ return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes);
+ } else {
+ throw new IllegalStateException(
+ "Target event source must be a subclass off "
+ + ManagedInformerEventSource.class.getName());
+ }
+ }
+
+ /**
+ * 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
+ */
+ public R resourcePatch(
+ R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) {
+ return 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.
+ * 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)
+ */
+ public P addFinalizer() {
+ return addFinalizer(context.getControllerConfiguration().getFinalizerName());
+ }
+
+ /**
+ * 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. 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
+ */
+ public P addFinalizer(String finalizerName) {
+ var resource = context.getPrimaryResource();
+ if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) {
+ return resource;
+ }
+ return conflictRetryingPatchPrimary(
+ 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. 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)
+ */
+ public P removeFinalizer() {
+ return removeFinalizer(context.getControllerConfiguration().getFinalizerName());
+ }
+
+ /**
+ * 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. 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
+ */
+ public P removeFinalizer(String finalizerName) {
+ var resource = context.getPrimaryResource();
+ if (!resource.hasFinalizer(finalizerName)) {
+ return resource;
+ }
+ return conflictRetryingPatchPrimary(
+ 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 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
+ * not.
+ * @return updated resource from the server or unchanged if the precondition does not hold.
+ */
+ @SuppressWarnings("unchecked")
+ public P conflictRetryingPatchPrimary(
+ 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(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. 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)
+ */
+ 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. 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
+ */
+ 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 {
+ @SuppressWarnings("unchecked")
+ P resource = (P) originalResource.getClass().getConstructor().newInstance();
+ resource.initNameAndNamespaceFrom(originalResource);
+ 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/processing/dependent/AbstractDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java
index a7c5ce9e2d..8dc62b4ca7 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java
@@ -23,6 +23,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.DefaultContext;
import io.javaoperatorsdk.operator.api.reconciler.Ignore;
import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
@@ -85,7 +86,7 @@ protected ReconcileResult reconcile(P primary, R actualResource, Context c
if (creatable() || updatable()) {
if (actualResource == null) {
if (creatable) {
- var desired = desired(primary, context);
+ var desired = getOrComputeDesired(context);
throwIfNull(desired, primary, "Desired");
logForOperation("Creating", primary, desired);
var createdResource = handleCreate(desired, primary, context);
@@ -95,7 +96,8 @@ protected ReconcileResult reconcile(P primary, R actualResource, Context c
if (updatable()) {
final Matcher.Result match = match(actualResource, primary, context);
if (!match.matched()) {
- final var desired = match.computedDesired().orElseGet(() -> desired(primary, context));
+ final var desired =
+ match.computedDesired().orElseGet(() -> getOrComputeDesired(context));
throwIfNull(desired, primary, "Desired");
logForOperation("Updating", primary, desired);
var updatedResource = handleUpdate(actualResource, desired, primary, context);
@@ -127,7 +129,6 @@ protected ReconcileResult reconcile(P primary, R actualResource, Context c
@Override
public Optional getSecondaryResource(P primary, Context context) {
-
var secondaryResources = context.getSecondaryResources(resourceType());
if (secondaryResources.isEmpty()) {
return Optional.empty();
@@ -212,6 +213,27 @@ protected R desired(P primary, Context
context) {
+ " updated");
}
+ /**
+ * Retrieves the desired state from the {@link Context} if it has already been computed or calls
+ * {@link #desired(HasMetadata, Context)} and stores its result in the context for further use.
+ * This ensures that {@code desired} is only called once per reconciliation to avoid unneeded
+ * processing and supports scenarios where idempotent computation of the desired state is not
+ * feasible.
+ *
+ *
Note that this method should normally only be called by the SDK itself and exclusively (i.e.
+ * {@link #desired(HasMetadata, Context)} should not be called directly by the SDK) whenever the
+ * desired state is needed to ensure it is properly cached for the current reconciliation.
+ *
+ * @param context the {@link Context} in scope for the current reconciliation
+ * @return the desired state associated with this dependent resource based on the currently
+ * in-scope primary resource as found in the context
+ */
+ protected R getOrComputeDesired(Context
context) {
+ assert context instanceof DefaultContext
;
+ DefaultContext
defaultContext = (DefaultContext
) context;
+ return defaultContext.getOrComputeDesiredStateFor(this, p -> desired(p, defaultContext));
+ }
+
public void delete(P primary, Context
context) {
dependentResourceReconciler.delete(primary, context);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java
index e601e937cf..7b83a377c1 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java
@@ -105,7 +105,7 @@ protected void handleExplicitStateCreation(P primary, R created, Context
cont
@Override
public Matcher.Result match(R resource, P primary, Context context) {
- var desired = desired(primary, context);
+ var desired = getOrComputeDesired(context);
return Matcher.Result.computed(resource.equals(desired), desired);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java
index 5b3617c26c..23135f81b1 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java
@@ -27,7 +27,6 @@
import io.javaoperatorsdk.operator.api.reconciler.Ignore;
import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter;
import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult;
-import io.javaoperatorsdk.operator.processing.dependent.Matcher.Result;
class BulkDependentResourceReconciler
implements DependentResourceReconciler {
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
index 0ba48797af..5562c883e2 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java
@@ -138,7 +138,7 @@ public static Matcher.Result m
Context context,
boolean labelsAndAnnotationsEquality,
String... ignorePaths) {
- final var desired = dependentResource.desired(primary, context);
+ final var desired = dependentResource.getOrComputeDesired(context);
return match(desired, actualResource, labelsAndAnnotationsEquality, context, ignorePaths);
}
@@ -150,7 +150,7 @@ public static Matcher.Result m
boolean specEquality,
boolean labelsAndAnnotationsEquality,
String... ignorePaths) {
- final var desired = dependentResource.desired(primary, context);
+ final var desired = dependentResource.getOrComputeDesired(context);
return match(
desired, actualResource, labelsAndAnnotationsEquality, specEquality, context, ignorePaths);
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
index 05cddcade1..f8d7c07b01 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java
@@ -25,7 +25,6 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.Namespaced;
-import io.fabric8.kubernetes.client.dsl.Resource;
import io.javaoperatorsdk.operator.api.config.dependent.Configured;
import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Context;
@@ -55,7 +54,6 @@ public abstract class KubernetesDependentResource kubernetesDependentResourceConfig;
private volatile Boolean useSSA;
- private volatile Boolean usePreviousAnnotationForEventFiltering;
public KubernetesDependentResource() {}
@@ -74,7 +72,8 @@ public void configureWith(KubernetesDependentResourceConfig config) {
@SuppressWarnings("unused")
public R create(R desired, P primary, Context context) {
- if (useSSA(context)) {
+ var ssa = useSSA(context);
+ if (ssa) {
// setting resource version for SSA so only created if it doesn't exist already
var createIfNotExisting =
kubernetesDependentResourceConfig == null
@@ -86,35 +85,40 @@ public R create(R desired, P primary, Context
context) {
}
}
addMetadata(false, null, desired, primary, context);
- final var resource = prepare(context, desired, primary, "Creating");
- return useSSA(context)
- ? resource
- .fieldManager(context.getControllerConfiguration().fieldManager())
- .forceConflicts()
- .serverSideApply()
- : resource.create();
+ log.debug(
+ "Creating target resource with type: {}, with id: {} use ssa: {}",
+ desired.getClass(),
+ ResourceID.fromResource(desired),
+ ssa);
+
+ return ssa
+ ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null))
+ : context.resourceOperations().create(desired, eventSource().orElse(null));
}
public R update(R actual, R desired, P primary, Context
context) {
- boolean useSSA = useSSA(context);
+ boolean ssa = useSSA(context);
if (log.isDebugEnabled()) {
log.debug(
"Updating actual resource: {} version: {}; SSA: {}",
ResourceID.fromResource(actual),
actual.getMetadata().getResourceVersion(),
- useSSA);
+ ssa);
}
R updatedResource;
addMetadata(false, actual, desired, primary, context);
- if (useSSA) {
+ log.debug(
+ "Updating target resource with type: {}, with id: {} use ssa: {}",
+ desired.getClass(),
+ ResourceID.fromResource(desired),
+ ssa);
+ if (ssa) {
updatedResource =
- prepare(context, desired, primary, "Updating")
- .fieldManager(context.getControllerConfiguration().fieldManager())
- .forceConflicts()
- .serverSideApply();
+ context.resourceOperations().serverSideApply(desired, eventSource().orElse(null));
} else {
var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context);
- updatedResource = prepare(context, updatedActual, primary, "Updating").update();
+ updatedResource =
+ context.resourceOperations().update(updatedActual, eventSource().orElse(null));
}
log.debug(
"Resource version after update: {}", updatedResource.getMetadata().getResourceVersion());
@@ -123,7 +127,7 @@ public R update(R actual, R desired, P primary, Context
context) {
@Override
public Result match(R actualResource, P primary, Context context) {
- final var desired = desired(primary, context);
+ final var desired = getOrComputeDesired(context);
return match(actualResource, desired, primary, context);
}
@@ -158,14 +162,6 @@ protected void addMetadata(
} else {
annotations.remove(InformerEventSource.PREVIOUS_ANNOTATION_KEY);
}
- } else if (usePreviousAnnotation(context)) { // set a new one
- eventSource()
- .orElseThrow()
- .addPreviousAnnotation(
- Optional.ofNullable(actualResource)
- .map(r -> r.getMetadata().getResourceVersion())
- .orElse(null),
- target);
}
addReferenceHandlingMetadata(target, primary);
}
@@ -181,22 +177,6 @@ protected boolean useSSA(Context
context) {
return useSSA;
}
- private boolean usePreviousAnnotation(Context
context) {
- if (usePreviousAnnotationForEventFiltering == null) {
- usePreviousAnnotationForEventFiltering =
- context
- .getControllerConfiguration()
- .getConfigurationService()
- .previousAnnotationForDependentResourcesEventFiltering()
- && !context
- .getControllerConfiguration()
- .getConfigurationService()
- .withPreviousAnnotationForDependentResourcesBlocklist()
- .contains(this.resourceType());
- }
- return usePreviousAnnotationForEventFiltering;
- }
-
@Override
protected void handleDelete(P primary, R secondary, Context
context) {
if (secondary != null) {
@@ -209,17 +189,6 @@ public void deleteTargetResource(P primary, R resource, ResourceID key, Context<
context.getClient().resource(resource).delete();
}
- @SuppressWarnings("unused")
- protected Resource prepare(Context context, R desired, P primary, String actionName) {
- log.debug(
- "{} target resource with type: {}, with id: {}",
- actionName,
- desired.getClass(),
- ResourceID.fromResource(desired));
-
- return context.getClient().resource(desired);
- }
-
protected void addReferenceHandlingMetadata(R desired, P primary) {
if (addOwnerReference()) {
desired.addOwnerReference(primary);
@@ -301,7 +270,7 @@ protected Optional selectTargetSecondaryResource(
* @return id of the target managed resource
*/
protected ResourceID targetSecondaryResourceID(P primary, Context context) {
- return ResourceID.fromResource(desired(primary, context));
+ return ResourceID.fromResource(getOrComputeDesired(context));
}
protected boolean addOwnerReference() {
@@ -309,8 +278,8 @@ protected boolean addOwnerReference() {
}
@Override
- protected R desired(P primary, Context
context) {
- return super.desired(primary, context);
+ protected R getOrComputeDesired(Context
context) {
+ return super.getOrComputeDesired(context);
}
@Override
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java
index 3685b509aa..b476c39614 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java
@@ -37,7 +37,7 @@
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter;
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter.RateLimitState;
import io.javaoperatorsdk.operator.processing.event.source.Cache;
-import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent;
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent;
import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource;
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java
index 411fc10e31..62e19394c8 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java
@@ -37,9 +37,9 @@
import io.javaoperatorsdk.operator.processing.LifecycleAware;
import io.javaoperatorsdk.operator.processing.event.source.EventSource;
import io.javaoperatorsdk.operator.processing.event.source.EventSourceStartPriority;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.ResourceEventAware;
import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource;
-import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource;
import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource;
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 da4ae9835a..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
@@ -15,25 +15,16 @@
*/
package io.javaoperatorsdk.operator.processing.event;
-import java.lang.reflect.InvocationTargetException;
import java.net.HttpURLConnection;
-import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
import io.fabric8.kubernetes.api.model.HasMetadata;
-import io.fabric8.kubernetes.api.model.KubernetesResourceList;
-import io.fabric8.kubernetes.api.model.Namespaced;
-import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.client.KubernetesClientException;
-import io.fabric8.kubernetes.client.dsl.MixedOperation;
-import io.fabric8.kubernetes.client.dsl.Resource;
-import io.fabric8.kubernetes.client.dsl.base.PatchContext;
-import io.fabric8.kubernetes.client.dsl.base.PatchType;
import io.javaoperatorsdk.operator.OperatorException;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.Cloner;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.BaseControl;
@@ -49,8 +40,6 @@
/** Handles calls and results of a Reconciler and finalizer related logic */
class ReconciliationDispatcher
{
- public static final int MAX_UPDATE_RETRY = 10;
-
private static final Logger log = LoggerFactory.getLogger(ReconciliationDispatcher.class);
private final Controller
controller;
@@ -76,7 +65,6 @@ public ReconciliationDispatcher(Controller
controller) {
this(
controller,
new CustomResourceFacade<>(
- controller.getCRClient(),
controller.getConfiguration(),
controller.getConfiguration().getConfigurationService().getResourceCloner()));
}
@@ -84,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);
@@ -109,17 +98,20 @@ && shouldNotDispatchToCleanupWhenMarkedForDeletion(originalResource)) {
originalResource.getMetadata().getFinalizers());
return PostExecutionControl.defaultDispatch();
}
- Context
context =
- new DefaultContext<>(
- executionScope.getRetryInfo(),
- controller,
- resourceForExecution,
- executionScope.isDeleteEvent(),
- executionScope.isDeleteFinalStateUnknown());
+ // context can be provided only for testing purposes
+ context =
+ context == null
+ ? new DefaultContext<>(
+ executionScope.getRetryInfo(),
+ controller,
+ resourceForExecution,
+ executionScope.isDeleteEvent(),
+ executionScope.isDeleteFinalStateUnknown())
+ : context;
// checking the cleaner for all-event-mode
if (!triggerOnAllEvents() && markedForDeletion) {
- return handleCleanup(resourceForExecution, originalResource, context, executionScope);
+ return handleCleanup(resourceForExecution, context, executionScope);
} else {
return handleReconcile(executionScope, resourceForExecution, originalResource, context);
}
@@ -148,11 +140,12 @@ private PostExecutionControl
handleReconcile(
*/
P updatedResource;
if (useSSA) {
- updatedResource = addFinalizerWithSSA(originalResource);
+ updatedResource = context.resourceOperations().addFinalizerWithSSA();
} else {
- updatedResource = updateCustomResourceWithFinalizer(resourceForExecution, originalResource);
+ updatedResource = context.resourceOperations().addFinalizer();
}
- return PostExecutionControl.onlyFinalizerAdded(updatedResource);
+ return PostExecutionControl.onlyFinalizerAdded(updatedResource)
+ .withReSchedule(BaseControl.INSTANT_RESCHEDULE);
} else {
try {
return reconcileExecution(executionScope, resourceForExecution, originalResource, context);
@@ -194,7 +187,7 @@ private PostExecutionControl
reconcileExecution(
}
if (updateControl.isPatchResource()) {
- updatedCustomResource = patchResource(toUpdate, originalResource);
+ updatedCustomResource = patchResource(context, toUpdate, originalResource);
if (!useSSA) {
toUpdate
.getMetadata()
@@ -203,7 +196,7 @@ private PostExecutionControl
reconcileExecution(
}
if (updateControl.isPatchStatus()) {
- customResourceFacade.patchStatus(toUpdate, originalResource);
+ customResourceFacade.patchStatus(context, toUpdate, originalResource);
}
return createPostExecutionControl(updatedCustomResource, updateControl, executionScope);
}
@@ -241,7 +234,7 @@ public boolean isLastAttempt() {
try {
updatedResource =
customResourceFacade.patchStatus(
- errorStatusUpdateControl.getResource().orElseThrow(), originalResource);
+ context, errorStatusUpdateControl.getResource().orElseThrow(), originalResource);
} catch (Exception ex) {
int code = ex instanceof KubernetesClientException kcex ? kcex.getCode() : -1;
Level exceptionLevel = Level.ERROR;
@@ -317,10 +310,7 @@ private void updatePostExecutionControlWithReschedule(
}
private PostExecutionControl
handleCleanup(
- P resourceForExecution,
- P originalResource,
- Context
context,
- ExecutionScope
executionScope) {
+ P resourceForExecution, Context
context, ExecutionScope
executionScope) {
if (log.isDebugEnabled()) {
log.debug(
"Executing delete for resource: {} with version: {}",
@@ -334,24 +324,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 =
- conflictRetryingPatch(
- resourceForExecution,
- originalResource,
- r -> {
- // the operator might not be allowed to retrieve the resource on a retry, e.g.
- // when its
- // permissions are removed by deleting the namespace concurrently
- if (r == null) {
- log.warn(
- "Could not remove finalizer on null resource: {} with version: {}",
- getUID(resourceForExecution),
- getVersion(resourceForExecution));
- return false;
- }
- return r.removeFinalizer(finalizerName);
- },
- true);
+ P customResource = context.resourceOperations().removeFinalizer();
return PostExecutionControl.customResourceFinalizerRemoved(customResource);
}
}
@@ -367,50 +340,14 @@ private PostExecutionControl
handleCleanup(
return postExecutionControl;
}
- @SuppressWarnings("unchecked")
- private P addFinalizerWithSSA(P originalResource) {
- 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(configuration().getFinalizerName());
- return customResourceFacade.patchResourceWithSSA(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);
+ private P patchResource(Context
context, P resource, P originalResource) {
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "Updating resource: {} with version: {}; SSA: {}",
+ resource.getMetadata().getName(),
+ getVersion(resource),
+ useSSA);
}
- }
-
- private P updateCustomResourceWithFinalizer(P resourceForExecution, P originalResource) {
- log.debug(
- "Adding finalizer for resource: {} version: {}",
- getUID(originalResource),
- getVersion(originalResource));
- return conflictRetryingPatch(
- resourceForExecution,
- originalResource,
- r -> r.addFinalizer(configuration().getFinalizerName()),
- false);
- }
-
- private P patchResource(P resource, P originalResource) {
- log.debug(
- "Updating resource: {} with version: {}; SSA: {}",
- getUID(resource),
- getVersion(resource),
- useSSA);
log.trace("Resource before update: {}", resource);
final var finalizerName = configuration().getFinalizerName();
@@ -418,64 +355,13 @@ private P patchResource(P resource, P originalResource) {
// addFinalizer already prevents adding an already present finalizer so no need to check
resource.addFinalizer(finalizerName);
}
- return customResourceFacade.patchResource(resource, originalResource);
+ return customResourceFacade.patchResource(context, resource, originalResource);
}
ControllerConfiguration
configuration() {
return controller.getConfiguration();
}
- public P conflictRetryingPatch(
- P resource,
- P originalResource,
- Function
modificationFunction,
- boolean forceNotUseSSA) {
- if (log.isDebugEnabled()) {
- log.debug("Conflict retrying update for: {}", ResourceID.fromResource(resource));
- }
- int retryIndex = 0;
- while (true) {
- try {
- var modified = modificationFunction.apply(resource);
- if (Boolean.FALSE.equals(modified)) {
- return resource;
- }
- if (forceNotUseSSA) {
- return customResourceFacade.patchResourceWithoutSSA(resource, originalResource);
- } else {
- return customResourceFacade.patchResource(resource, originalResource);
- }
- } 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 >= MAX_UPDATE_RETRY) {
- throw new OperatorException(
- "Exceeded maximum ("
- + MAX_UPDATE_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());
- resource =
- customResourceFacade.getResource(
- resource.getMetadata().getNamespace(), resource.getMetadata().getName());
- }
- }
- }
-
private void validateExecutionScope(ExecutionScope
executionScope) {
if (!triggerOnAllEvents()
&& (executionScope.isDeleteEvent() || executionScope.isDeleteFinalStateUnknown())) {
@@ -488,34 +374,15 @@ private void validateExecutionScope(ExecutionScope
executionScope) {
// created to support unit testing
static class CustomResourceFacade {
- private final MixedOperation, Resource> resourceOperation;
private final boolean useSSA;
- private final String fieldManager;
private final Cloner cloner;
- public CustomResourceFacade(
- MixedOperation, Resource> resourceOperation,
- ControllerConfiguration configuration,
- Cloner cloner) {
- this.resourceOperation = resourceOperation;
+ public CustomResourceFacade(ControllerConfiguration configuration, Cloner cloner) {
this.useSSA = configuration.getConfigurationService().useSSAToPatchPrimaryResource();
- this.fieldManager = configuration.fieldManager();
this.cloner = cloner;
}
- public R getResource(String namespace, String name) {
- if (namespace != null) {
- return resourceOperation.inNamespace(namespace).withName(name).get();
- } else {
- return resourceOperation.withName(name).get();
- }
- }
-
- public R patchResourceWithoutSSA(R resource, R originalResource) {
- return resource(originalResource).edit(r -> resource);
- }
-
- public R patchResource(R resource, R originalResource) {
+ public R patchResource(Context context, R resource, R originalResource) {
if (log.isDebugEnabled()) {
log.debug(
"Trying to replace resource {}, version: {}",
@@ -523,35 +390,28 @@ public R patchResource(R resource, R originalResource) {
resource.getMetadata().getResourceVersion());
}
if (useSSA) {
- return patchResourceWithSSA(resource);
+ return context.resourceOperations().serverSideApplyPrimary(resource);
} else {
- return resource(originalResource).edit(r -> resource);
+ return context.resourceOperations().jsonPatchPrimary(originalResource, r -> resource);
}
}
- public R patchStatus(R resource, R originalResource) {
+ public R patchStatus(Context context, R resource, R originalResource) {
log.trace("Patching status for resource: {} with ssa: {}", resource, useSSA);
if (useSSA) {
var managedFields = resource.getMetadata().getManagedFields();
try {
resource.getMetadata().setManagedFields(null);
- var res = resource(resource);
- return res.subresource("status")
- .patch(
- new PatchContext.Builder()
- .withFieldManager(fieldManager)
- .withForce(true)
- .withPatchType(PatchType.SERVER_SIDE_APPLY)
- .build());
+ return context.resourceOperations().serverSideApplyPrimaryStatus(resource);
} finally {
resource.getMetadata().setManagedFields(managedFields);
}
} else {
- return editStatus(resource, originalResource);
+ return editStatus(context, resource, originalResource);
}
}
- private R editStatus(R resource, R originalResource) {
+ private R editStatus(Context context, R resource, R originalResource) {
String resourceVersion = resource.getMetadata().getResourceVersion();
// the cached resource should not be changed in any circumstances
// that can lead to all kinds of race conditions.
@@ -559,34 +419,20 @@ private R editStatus(R resource, R originalResource) {
try {
clonedOriginal.getMetadata().setResourceVersion(null);
resource.getMetadata().setResourceVersion(null);
- var res = resource(clonedOriginal);
- return res.editStatus(
- r -> {
- ReconcilerUtils.setStatus(r, ReconcilerUtils.getStatus(resource));
- return r;
- });
+ return context
+ .resourceOperations()
+ .jsonPatchPrimaryStatus(
+ clonedOriginal,
+ r -> {
+ ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource));
+ return r;
+ });
} finally {
// restore initial resource version
clonedOriginal.getMetadata().setResourceVersion(resourceVersion);
resource.getMetadata().setResourceVersion(resourceVersion);
}
}
-
- public R patchResourceWithSSA(R resource) {
- return resource(resource)
- .patch(
- new PatchContext.Builder()
- .withFieldManager(fieldManager)
- .withForce(true)
- .withPatchType(PatchType.SERVER_SIDE_APPLY)
- .build());
- }
-
- private Resource resource(R resource) {
- return resource instanceof Namespaced
- ? resourceOperation.inNamespace(resource.getMetadata().getNamespace()).resource(resource)
- : resourceOperation.resource(resource);
- }
}
private boolean triggerOnAllEvents() {
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java
index 9db8c7539f..da408322f1 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java
@@ -63,9 +63,28 @@ public boolean equals(Object o) {
}
public boolean isSameResource(HasMetadata hasMetadata) {
+ if (hasMetadata == null) {
+ return false;
+ }
final var metadata = hasMetadata.getMetadata();
- return getName().equals(metadata.getName())
- && getNamespace().map(ns -> ns.equals(metadata.getNamespace())).orElse(true);
+ return isSameResource(metadata.getName(), metadata.getNamespace());
+ }
+
+ /**
+ * Whether this ResourceID points to the same resource as the one identified by the specified name
+ * and namespace.
+ *
+ * Note that this doesn't take API version or Kind into account so this should only be used
+ * when checking resources that are reasonably expected to be of the same type.
+ *
+ * @param name the name of the resource we want to check
+ * @param namespace the possibly {@code null} namespace of the resource we want to check
+ * @return {@code true} if this resource points to the same resource as the one pointed to by the
+ * specified name and namespace, {@code false} otherwise
+ * @since 5.3.0
+ */
+ public boolean isSameResource(String name, String namespace) {
+ return Objects.equals(this.name, name) && Objects.equals(this.namespace, namespace);
}
@Override
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java
new file mode 100644
index 0000000000..3e1a4f9b14
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java
@@ -0,0 +1,15 @@
+/*
+ * 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.
+ */
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceAction.java
similarity index 90%
rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java
rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceAction.java
index 33c4c5a2d6..fff8680913 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceAction.java
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package io.javaoperatorsdk.operator.processing.event.source.controller;
+package io.javaoperatorsdk.operator.processing.event.source;
public enum ResourceAction {
ADDED,
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java
index b7a6406e20..8412e1ccbe 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java
@@ -28,11 +28,13 @@
import io.javaoperatorsdk.operator.processing.Controller;
import io.javaoperatorsdk.operator.processing.MDCUtils;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.filter.OnDeleteFilter;
import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter;
import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource;
+import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling;
-import static io.javaoperatorsdk.operator.ReconcilerUtils.handleKubernetesClientException;
+import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.handleKubernetesClientException;
import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion;
import static io.javaoperatorsdk.operator.processing.event.source.controller.InternalEventFilters.*;
@@ -47,7 +49,11 @@ public class ControllerEventSource
@SuppressWarnings({"unchecked", "rawtypes"})
public ControllerEventSource(Controller controller) {
- super(NAME, controller.getCRClient(), controller.getConfiguration(), false);
+ super(
+ NAME,
+ controller.getCRClient(),
+ controller.getConfiguration(),
+ controller.getConfiguration().getInformerConfig().isComparableResourceVersions());
this.controller = controller;
final var config = controller.getConfiguration();
@@ -77,7 +83,8 @@ public synchronized void start() {
}
}
- public void eventReceived(
+ @Override
+ protected synchronized void handleEvent(
ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) {
try {
if (log.isDebugEnabled()) {
@@ -127,21 +134,36 @@ private boolean isAcceptedByFilters(ResourceAction action, T resource, T oldReso
}
@Override
- public void onAdd(T resource) {
- super.onAdd(resource);
- eventReceived(ResourceAction.ADDED, resource, null, null);
+ public synchronized void onAdd(T resource) {
+ handleOnAddOrUpdate(ResourceAction.ADDED, null, resource);
}
@Override
- public void onUpdate(T oldCustomResource, T newCustomResource) {
- super.onUpdate(oldCustomResource, newCustomResource);
- eventReceived(ResourceAction.UPDATED, newCustomResource, oldCustomResource, null);
+ public synchronized void onUpdate(T oldCustomResource, T newCustomResource) {
+ handleOnAddOrUpdate(ResourceAction.UPDATED, oldCustomResource, newCustomResource);
+ }
+
+ private void handleOnAddOrUpdate(
+ ResourceAction action, T oldCustomResource, T newCustomResource) {
+ var handling =
+ temporaryResourceCache.onAddOrUpdateEvent(action, newCustomResource, oldCustomResource);
+ if (handling == EventHandling.NEW) {
+ handleEvent(action, newCustomResource, oldCustomResource, null);
+ } else if (log.isDebugEnabled()) {
+ log.debug(
+ "{} event propagation for action: {} resource id: {} ",
+ handling,
+ action,
+ ResourceID.fromResource(newCustomResource));
+ }
}
@Override
- public void onDelete(T resource, boolean deletedFinalStateUnknown) {
- super.onDelete(resource, deletedFinalStateUnknown);
- eventReceived(ResourceAction.DELETED, resource, null, deletedFinalStateUnknown);
+ public synchronized void onDelete(T resource, boolean deletedFinalStateUnknown) {
+ temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown);
+ // delete event is quite special here, that requires special care, since we clean up caches on
+ // delete event.
+ handleEvent(ResourceAction.DELETED, resource, null, deletedFinalStateUnknown);
}
@Override
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java
index ac21250051..6219207faf 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceDeleteEvent.java
@@ -19,6 +19,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
/**
* Extends ResourceEvent for informer Delete events, it holds also information if the final state is
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java
index 395f3755fb..88f9bf8716 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java
@@ -21,6 +21,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.javaoperatorsdk.operator.processing.event.Event;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
public class ResourceEvent extends Event {
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java
new file mode 100644
index 0000000000..8b573a986c
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.event.source.informer;
+
+import java.util.Optional;
+
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
+import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent;
+
+class EventFilterDetails {
+
+ private int activeUpdates = 0;
+ private ResourceEvent lastEvent;
+
+ public void increaseActiveUpdates() {
+ activeUpdates = activeUpdates + 1;
+ }
+
+ public boolean decreaseActiveUpdates() {
+ activeUpdates = activeUpdates - 1;
+ return activeUpdates == 0;
+ }
+
+ public void setLastEvent(ResourceEvent event) {
+ lastEvent = event;
+ }
+
+ public Optional getLatestEventAfterLastUpdateEvent(String updatedResourceVersion) {
+ if (lastEvent != null
+ && (updatedResourceVersion == null
+ || ReconcilerUtilsInternal.compareResourceVersions(
+ lastEvent.getResource().orElseThrow().getMetadata().getResourceVersion(),
+ updatedResourceVersion)
+ > 0)) {
+ return Optional.of(lastEvent);
+ }
+ return Optional.empty();
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java
new file mode 100644
index 0000000000..4ae476a3de
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.event.source.informer;
+
+import java.util.Optional;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
+import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent;
+
+/** Used only for resource event filtering. */
+public class ExtendedResourceEvent extends ResourceEvent {
+
+ private HasMetadata previousResource;
+
+ public ExtendedResourceEvent(
+ ResourceAction action,
+ ResourceID resourceID,
+ HasMetadata latestResource,
+ HasMetadata previousResource) {
+ super(action, resourceID, latestResource);
+ this.previousResource = previousResource;
+ }
+
+ public Optional getPreviousResource() {
+ return Optional.ofNullable(previousResource);
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java
index ec11db25f4..b778747417 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java
@@ -17,7 +17,6 @@
import java.util.Optional;
import java.util.Set;
-import java.util.UUID;
import java.util.stream.Collectors;
import org.slf4j.Logger;
@@ -28,43 +27,21 @@
import io.fabric8.kubernetes.client.dsl.MixedOperation;
import io.fabric8.kubernetes.client.informers.ResourceEventHandler;
import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration;
-import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext;
import io.javaoperatorsdk.operator.processing.event.Event;
import io.javaoperatorsdk.operator.processing.event.EventHandler;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
+import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling;
+
+import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION;
/**
* Wraps informer(s) so they are connected to the eventing system of the framework. Note that since
* this is built on top of Fabric8 client Informers, it also supports caching resources using
- * caching from informer caches as well as additional caches described below.
- *
- * InformerEventSource also supports two features to better handle events and caching of
- * resources on top of Informers from the Fabric8 Kubernetes client. These two features are related
- * to each other as follows:
- *
- *
- * - Ensuring the cache contains the fresh resource after an update. This is important for
- * {@link io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource} and mainly
- * for {@link
- * io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource} so
- * that {@link
- * io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource#getSecondaryResource(HasMetadata,
- * Context)} always returns the latest version of the resource after a reconciliation. To
- * achieve this {@link #handleRecentResourceUpdate(ResourceID, HasMetadata, HasMetadata)} and
- * {@link #handleRecentResourceCreate(ResourceID, HasMetadata)} need to be called explicitly
- * after a resource is created or updated using the kubernetes client. These calls are done
- * automatically by the KubernetesDependentResource implementation. In the background this
- * will store the new resource in a temporary cache {@link TemporaryResourceCache} which does
- * additional checks. After a new event is received the cached object is removed from this
- * cache, since it is then usually already in the informer cache.
- *
- Avoiding unneeded reconciliations after resources are created or updated. This filters out
- * events that are the results of updates and creates made by the controller itself because we
- * typically don't want the associated informer to trigger an event causing a useless
- * reconciliation (as the change originates from the reconciler itself). For the details see
- * {@link #canSkipEvent(HasMetadata, HasMetadata, ResourceID)} and related usage.
- *
+ * caching from informer caches as well as filtering events which are result of the controller's
+ * update.
*
* @param resource type being watched
* @param type of the associated primary resource
@@ -78,28 +55,24 @@ public class InformerEventSource
// we need direct control for the indexer to propagate the just update resource also to the index
private final PrimaryToSecondaryIndex primaryToSecondaryIndex;
private final PrimaryToSecondaryMapper primaryToSecondaryMapper;
- private final String id = UUID.randomUUID().toString();
public InformerEventSource(
InformerEventSourceConfiguration configuration, EventSourceContext context) {
this(
configuration,
configuration.getKubernetesClient().orElse(context.getClient()),
- context
- .getControllerConfiguration()
- .getConfigurationService()
- .parseResourceVersionsForEventFilteringAndCaching());
+ configuration.comparableResourceVersion());
}
InformerEventSource(InformerEventSourceConfiguration configuration, KubernetesClient client) {
- this(configuration, client, false);
+ this(configuration, client, DEFAULT_COMPARABLE_RESOURCE_VERSION);
}
@SuppressWarnings({"unchecked", "rawtypes"})
private InformerEventSource(
InformerEventSourceConfiguration configuration,
KubernetesClient client,
- boolean parseResourceVersions) {
+ boolean comparableResourceVersions) {
super(
configuration.name(),
configuration
@@ -107,7 +80,7 @@ private InformerEventSource(
.map(gvk -> client.genericKubernetesResources(gvk.apiVersion(), gvk.getKind()))
.orElseGet(() -> (MixedOperation) client.resources(configuration.getResourceClass())),
configuration,
- parseResourceVersions);
+ comparableResourceVersions);
// If there is a primary to secondary mapper there is no need for primary to secondary index.
primaryToSecondaryMapper = configuration.getPrimaryToSecondaryMapper();
if (useSecondaryToPrimaryIndex()) {
@@ -134,9 +107,7 @@ public void onAdd(R newResource) {
resourceType().getSimpleName(),
newResource.getMetadata().getResourceVersion());
}
- primaryToSecondaryIndex.onAddOrUpdate(newResource);
- onAddOrUpdate(
- Operation.ADD, newResource, null, () -> InformerEventSource.super.onAdd(newResource));
+ onAddOrUpdate(ResourceAction.ADDED, newResource, null);
}
@Override
@@ -149,16 +120,11 @@ public void onUpdate(R oldObject, R newObject) {
newObject.getMetadata().getResourceVersion(),
oldObject.getMetadata().getResourceVersion());
}
- primaryToSecondaryIndex.onAddOrUpdate(newObject);
- onAddOrUpdate(
- Operation.UPDATE,
- newObject,
- oldObject,
- () -> InformerEventSource.super.onUpdate(oldObject, newObject));
+ onAddOrUpdate(ResourceAction.UPDATED, newObject, oldObject);
}
@Override
- public void onDelete(R resource, boolean b) {
+ public synchronized void onDelete(R resource, boolean b) {
if (log.isDebugEnabled()) {
log.debug(
"On delete event received for resource id: {} type: {}",
@@ -166,12 +132,18 @@ public void onDelete(R resource, boolean b) {
resourceType().getSimpleName());
}
primaryToSecondaryIndex.onDelete(resource);
- super.onDelete(resource, b);
+ temporaryResourceCache.onDeleteEvent(resource, b);
if (acceptedByDeleteFilters(resource, b)) {
propagateEvent(resource);
}
}
+ @Override
+ protected void handleEvent(
+ ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown) {
+ propagateEvent(resource);
+ }
+
@Override
public synchronized void start() {
super.start();
@@ -180,68 +152,30 @@ public synchronized void start() {
manager().list().forEach(primaryToSecondaryIndex::onAddOrUpdate);
}
- private synchronized void onAddOrUpdate(
- Operation operation, R newObject, R oldObject, Runnable superOnOp) {
+ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) {
+ primaryToSecondaryIndex.onAddOrUpdate(newObject);
var resourceID = ResourceID.fromResource(newObject);
- if (canSkipEvent(newObject, oldObject, resourceID)) {
+ var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject);
+
+ if (eventHandling != EventHandling.NEW) {
log.debug(
- "Skipping event propagation for {}, since was a result of a reconcile action. Resource"
- + " ID: {}",
- operation,
+ "{} event propagation for {}. Resource ID: {}",
+ eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping",
+ action,
ResourceID.fromResource(newObject));
- superOnOp.run();
+ } else if (eventAcceptedByFilter(action, newObject, oldObject)) {
+ log.debug(
+ "Propagating event for {}, resource with same version not result of a reconciliation."
+ + " Resource ID: {}",
+ action,
+ resourceID);
+ propagateEvent(newObject);
} else {
- superOnOp.run();
- if (eventAcceptedByFilter(operation, newObject, oldObject)) {
- log.debug(
- "Propagating event for {}, resource with same version not result of a reconciliation."
- + " Resource ID: {}",
- operation,
- resourceID);
- propagateEvent(newObject);
- } else {
- log.debug("Event filtered out for operation: {}, resourceID: {}", operation, resourceID);
- }
+ log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID);
}
}
- private boolean canSkipEvent(R newObject, R oldObject, ResourceID resourceID) {
- var res = temporaryResourceCache.getResourceFromCache(resourceID);
- if (res.isEmpty()) {
- return isEventKnownFromAnnotation(newObject, oldObject);
- }
- boolean resVersionsEqual =
- newObject
- .getMetadata()
- .getResourceVersion()
- .equals(res.get().getMetadata().getResourceVersion());
- log.debug(
- "Resource found in temporal cache for id: {} resource versions equal: {}",
- resourceID,
- resVersionsEqual);
- return resVersionsEqual
- || temporaryResourceCache.isLaterResourceVersion(resourceID, res.get(), newObject);
- }
-
- private boolean isEventKnownFromAnnotation(R newObject, R oldObject) {
- String previous = newObject.getMetadata().getAnnotations().get(PREVIOUS_ANNOTATION_KEY);
- boolean known = false;
- if (previous != null) {
- String[] parts = previous.split(",");
- if (id.equals(parts[0])) {
- if (oldObject == null && parts.length == 1) {
- known = true;
- } else if (oldObject != null
- && parts.length == 2
- && oldObject.getMetadata().getResourceVersion().equals(parts[1])) {
- known = true;
- }
- }
- }
- return known;
- }
-
private void propagateEvent(R object) {
var primaryResourceIdSet =
configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(object);
@@ -284,28 +218,25 @@ public Set getSecondaryResources(P primary) {
}
return secondaryIDs.stream()
.map(this::get)
- .flatMap(Optional::stream)
+ .filter(Optional::isPresent)
+ .map(Optional::get)
.collect(Collectors.toSet());
}
@Override
- public synchronized void handleRecentResourceUpdate(
+ public void handleRecentResourceUpdate(
ResourceID resourceID, R resource, R previousVersionOfResource) {
- handleRecentCreateOrUpdate(Operation.UPDATE, resource, previousVersionOfResource);
+ handleRecentCreateOrUpdate(resource);
}
@Override
- public synchronized void handleRecentResourceCreate(ResourceID resourceID, R resource) {
- handleRecentCreateOrUpdate(Operation.ADD, resource, null);
+ public void handleRecentResourceCreate(ResourceID resourceID, R resource) {
+ handleRecentCreateOrUpdate(resource);
}
- private void handleRecentCreateOrUpdate(Operation operation, R newResource, R oldResource) {
+ private void handleRecentCreateOrUpdate(R newResource) {
primaryToSecondaryIndex.onAddOrUpdate(newResource);
- temporaryResourceCache.putResource(
- newResource,
- Optional.ofNullable(oldResource)
- .map(r -> r.getMetadata().getResourceVersion())
- .orElse(null));
+ temporaryResourceCache.putResource(newResource);
}
private boolean useSecondaryToPrimaryIndex() {
@@ -317,11 +248,11 @@ public boolean allowsNamespaceChanges() {
return configuration().followControllerNamespaceChanges();
}
- private boolean eventAcceptedByFilter(Operation operation, R newObject, R oldObject) {
+ private boolean eventAcceptedByFilter(ResourceAction action, R newObject, R oldObject) {
if (genericFilter != null && !genericFilter.accept(newObject)) {
return false;
}
- if (operation == Operation.ADD) {
+ if (action == ResourceAction.ADDED) {
return onAddFilter == null || onAddFilter.accept(newObject);
} else {
return onUpdateFilter == null || onUpdateFilter.accept(newObject, oldObject);
@@ -332,25 +263,4 @@ private boolean acceptedByDeleteFilters(R resource, boolean b) {
return (onDeleteFilter == null || onDeleteFilter.accept(resource, b))
&& (genericFilter == null || genericFilter.accept(resource));
}
-
- /**
- * Add an annotation to the resource so that the subsequent will be omitted
- *
- * @param resourceVersion null if there is no prior version
- * @param target mutable resource that will be returned
- */
- public R addPreviousAnnotation(String resourceVersion, R target) {
- target
- .getMetadata()
- .getAnnotations()
- .put(
- PREVIOUS_ANNOTATION_KEY,
- id + Optional.ofNullable(resourceVersion).map(rv -> "," + rv).orElse(""));
- return target;
- }
-
- private enum Operation {
- ADD,
- UPDATE
- }
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java
index abd2b6a752..42e06c9d9a 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java
@@ -32,7 +32,7 @@
import io.fabric8.kubernetes.client.dsl.Resource;
import io.fabric8.kubernetes.client.informers.ResourceEventHandler;
import io.javaoperatorsdk.operator.OperatorException;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.Informable;
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
@@ -253,7 +253,7 @@ public String toString() {
final var informerConfig = configuration.getInformerConfig();
final var selector = informerConfig.getLabelSelector();
return "InformerManager ["
- + ReconcilerUtils.getResourceTypeNameWithVersion(configuration.getResourceClass())
+ + ReconcilerUtilsInternal.getResourceTypeNameWithVersion(configuration.getResourceClass())
+ "] watching: "
+ informerConfig.getEffectiveNamespaces(controllerConfiguration)
+ (selector != null ? " selector: " + selector : "");
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java
index 2a6c7ef206..60497bc0c9 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java
@@ -35,7 +35,7 @@
import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
import io.fabric8.kubernetes.client.informers.cache.Cache;
import io.javaoperatorsdk.operator.OperatorException;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
import io.javaoperatorsdk.operator.health.InformerHealthIndicator;
import io.javaoperatorsdk.operator.health.Status;
@@ -131,7 +131,7 @@ public void start() throws OperatorException {
}
} catch (Exception e) {
- ReconcilerUtils.handleKubernetesClientException(
+ ReconcilerUtilsInternal.handleKubernetesClientException(
e, HasMetadata.getFullResourceName(informer.getApiTypeClass()));
throw new OperatorException(
"Couldn't start informer for " + versionedFullResourceName() + " resources", e);
@@ -143,7 +143,7 @@ private String versionedFullResourceName() {
if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) {
return GenericKubernetesResource.class.getSimpleName();
}
- return ReconcilerUtils.getResourceTypeNameWithVersion(apiTypeClass);
+ return ReconcilerUtilsInternal.getResourceTypeNameWithVersion(apiTypeClass);
}
@Override
@@ -156,6 +156,10 @@ public Optional get(ResourceID resourceID) {
return Optional.ofNullable(cache.getByKey(getKey(resourceID)));
}
+ public String getLastSyncResourceVersion() {
+ return this.informer.lastSyncResourceVersion();
+ }
+
private String getKey(ResourceID resourceID) {
return Cache.namespaceKeyFunc(resourceID.getNamespace().orElse(null), resourceID.getName());
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java
index 2679918b60..dcfe687a2f 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java
@@ -22,6 +22,7 @@
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import org.slf4j.Logger;
@@ -31,6 +32,7 @@
import io.fabric8.kubernetes.client.dsl.MixedOperation;
import io.fabric8.kubernetes.client.informers.ResourceEventHandler;
import io.javaoperatorsdk.operator.OperatorException;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.Informable;
import io.javaoperatorsdk.operator.api.config.NamespaceChangeable;
@@ -40,6 +42,8 @@
import io.javaoperatorsdk.operator.health.Status;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
import io.javaoperatorsdk.operator.processing.event.source.*;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
+import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent;
@SuppressWarnings("rawtypes")
public abstract class ManagedInformerEventSource<
@@ -55,7 +59,7 @@ public abstract class ManagedInformerEventSource<
private static final Logger log = LoggerFactory.getLogger(ManagedInformerEventSource.class);
private InformerManager cache;
- private final boolean parseResourceVersions;
+ private final boolean comparableResourceVersions;
private ControllerConfiguration controllerConfiguration;
private final C configuration;
private final Map>> indexers = new HashMap<>();
@@ -63,28 +67,13 @@ public abstract class ManagedInformerEventSource<
protected MixedOperation client;
protected ManagedInformerEventSource(
- String name, MixedOperation client, C configuration, boolean parseResourceVersions) {
+ String name, MixedOperation client, C configuration, boolean comparableResourceVersions) {
super(configuration.getResourceClass(), name);
- this.parseResourceVersions = parseResourceVersions;
+ this.comparableResourceVersions = comparableResourceVersions;
this.client = client;
this.configuration = configuration;
}
- @Override
- public void onAdd(R resource) {
- temporaryResourceCache.onAddOrUpdateEvent(resource);
- }
-
- @Override
- public void onUpdate(R oldObj, R newObj) {
- temporaryResourceCache.onAddOrUpdateEvent(newObj);
- }
-
- @Override
- public void onDelete(R obj, boolean deletedFinalStateUnknown) {
- temporaryResourceCache.onDeleteEvent(obj, deletedFinalStateUnknown);
- }
-
protected InformerManager manager() {
return cache;
}
@@ -96,13 +85,69 @@ public void changeNamespaces(Set namespaces) {
}
}
+ /**
+ * Updates the resource and makes sure that the response is available for the next reconciliation.
+ * Also makes sure that the even produced by this update is filtered, thus does not trigger the
+ * reconciliation.
+ */
+ @SuppressWarnings("unchecked")
+ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) {
+ ResourceID id = ResourceID.fromResource(resourceToUpdate);
+ if (log.isDebugEnabled()) {
+ log.debug("Update and cache: {}", id);
+ }
+ R updatedResource = null;
+ try {
+ temporaryResourceCache.startEventFilteringModify(id);
+ updatedResource = updateMethod.apply(resourceToUpdate);
+ handleRecentResourceUpdate(id, updatedResource, resourceToUpdate);
+ return updatedResource;
+ } finally {
+ var res =
+ temporaryResourceCache.doneEventFilterModify(
+ id,
+ updatedResource == null ? null : updatedResource.getMetadata().getResourceVersion());
+ var updatedForLambda = updatedResource;
+ res.ifPresentOrElse(
+ r -> {
+ R latestResource = (R) r.getResource().orElseThrow();
+
+ // as previous resource version we use the one from successful update, since
+ // we process new event here only if that is more recent then the event from our update.
+ // Note that this is equivalent with the scenario when an informer watch connection
+ // would
+ // reconnect and loose some events in between.
+ // If that update was not successful we still record the previous version from the
+ // actual
+ // event in the ExtendedResourceEvent.
+ R extendedResourcePrevVersion =
+ (r instanceof ExtendedResourceEvent)
+ ? (R) ((ExtendedResourceEvent) r).getPreviousResource().orElse(null)
+ : null;
+ R prevVersionOfResource =
+ updatedForLambda != null ? updatedForLambda : extendedResourcePrevVersion;
+ handleEvent(
+ r.getAction(),
+ latestResource,
+ prevVersionOfResource,
+ (r instanceof ResourceDeleteEvent)
+ ? ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown()
+ : null);
+ },
+ () -> log.debug("No new event present after the filtering update; id: {}", id));
+ }
+ }
+
+ protected abstract void handleEvent(
+ ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown);
+
@SuppressWarnings("unchecked")
@Override
public synchronized void start() {
if (isRunning()) {
return;
}
- temporaryResourceCache = new TemporaryResourceCache<>(this, parseResourceVersions);
+ temporaryResourceCache = new TemporaryResourceCache<>(comparableResourceVersions);
this.cache = new InformerManager<>(client, configuration, this);
cache.setControllerConfiguration(controllerConfiguration);
cache.addIndexers(indexers);
@@ -122,30 +167,32 @@ public synchronized void stop() {
@Override
public void handleRecentResourceUpdate(
ResourceID resourceID, R resource, R previousVersionOfResource) {
- temporaryResourceCache.putResource(
- resource, previousVersionOfResource.getMetadata().getResourceVersion());
+ temporaryResourceCache.putResource(resource);
}
@Override
public void handleRecentResourceCreate(ResourceID resourceID, R resource) {
- temporaryResourceCache.putAddedResource(resource);
+ temporaryResourceCache.putResource(resource);
}
@Override
public Optional get(ResourceID resourceID) {
+ var res = cache.get(resourceID);
Optional resource = temporaryResourceCache.getResourceFromCache(resourceID);
- if (resource.isPresent()) {
- log.debug("Resource found in temporary cache for Resource ID: {}", resourceID);
+ if (comparableResourceVersions
+ && resource.isPresent()
+ && res.filter(
+ r -> ReconcilerUtilsInternal.compareResourceVersions(r, resource.orElseThrow()) > 0)
+ .isEmpty()) {
+ log.debug("Latest resource found in temporary cache for Resource ID: {}", resourceID);
return resource;
- } else {
- log.debug(
- "Resource not found in temporary cache reading it from informer cache,"
- + " for Resource ID: {}",
- resourceID);
- var res = cache.get(resourceID);
- log.debug("Resource found in cache: {} for id: {}", res.isPresent(), resourceID);
- return res;
}
+ log.debug(
+ "Resource not found, or older, in temporary cache. Found in informer cache {}, for"
+ + " Resource ID: {}",
+ res.isPresent(),
+ resourceID);
+ return res;
}
@SuppressWarnings("unused")
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java
index 06226ae4ba..6e1d30c323 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java
@@ -15,7 +15,7 @@
*/
package io.javaoperatorsdk.operator.processing.event.source.informer;
-import java.util.LinkedHashMap;
+import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@@ -24,166 +24,178 @@
import org.slf4j.LoggerFactory;
import io.fabric8.kubernetes.api.model.HasMetadata;
-import io.javaoperatorsdk.operator.api.config.ConfigurationService;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
+import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent;
+import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent;
/**
* Temporal cache is used to solve the problem for {@link KubernetesDependentResource} that is, when
* a create or update is executed the subsequent getResource operation might not return the
* up-to-date resource from informer cache, since it is not received yet.
*
- * The idea of the solution is, that since an update (for create is simpler) was done
- * successfully, and optimistic locking is in place, there were no other operations between reading
- * the resource from the cache and the actual update. So when the new resource is stored in the
- * temporal cache only if the informer still has the previous resource version, from before the
- * update. If not, that means there were already updates on the cache (either by the actual update
- * from DependentResource or other) so the resource does not needs to be cached. Subsequently if
- * event received from the informer, it means that the cache of the informer was updated, so it
- * already contains a more fresh version of the resource.
+ *
Since an update (for create is simpler) was done successfully we can temporarily track that
+ * resource if its version is later than the events we've processed. We then know that we can skip
+ * all events that have the same resource version or earlier than the tracked resource. Once we
+ * process an event that has the same resource version or later, then we know the tracked resource
+ * can be removed.
+ *
+ *
In some cases it is possible for the informer to deliver events prior to the attempt to put
+ * the resource in the temporal cache. The startModifying/doneModifying methods are used to pause
+ * event delivery to ensure that temporal cache recognizes the put entry as an event that can be
+ * skipped.
+ *
+ *
If comparable resource versions are disabled, then this cache is effectively disabled.
*
* @param resource to cache.
*/
public class TemporaryResourceCache {
- static class ExpirationCache {
- private final LinkedHashMap cache;
- private final int ttlMs;
-
- public ExpirationCache(int maxEntries, int ttlMs) {
- this.ttlMs = ttlMs;
- this.cache =
- new LinkedHashMap<>() {
- @Override
- protected boolean removeEldestEntry(Map.Entry eldest) {
- return size() > maxEntries;
- }
- };
- }
-
- public void add(K key) {
- clean();
- cache.putIfAbsent(key, System.currentTimeMillis());
- }
-
- public boolean contains(K key) {
- clean();
- return cache.get(key) != null;
- }
-
- void clean() {
- if (!cache.isEmpty()) {
- long currentTimeMillis = System.currentTimeMillis();
- var iter = cache.entrySet().iterator();
- // the order will already be from oldest to newest, clean a fixed number of entries to
- // amortize the cost amongst multiple calls
- for (int i = 0; i < 10 && iter.hasNext(); i++) {
- var entry = iter.next();
- if (currentTimeMillis - entry.getValue() > ttlMs) {
- iter.remove();
- }
- }
- }
- }
- }
-
private static final Logger log = LoggerFactory.getLogger(TemporaryResourceCache.class);
private final Map cache = new ConcurrentHashMap<>();
+ private final boolean comparableResourceVersions;
+ private String latestResourceVersion;
- // keep up to the last million deletions for up to 10 minutes
- private final ExpirationCache tombstones = new ExpirationCache<>(1000000, 1200000);
- private final ManagedInformerEventSource managedInformerEventSource;
- private final boolean parseResourceVersions;
+ private final Map activeUpdates = new HashMap<>();
- public TemporaryResourceCache(
- ManagedInformerEventSource managedInformerEventSource,
- boolean parseResourceVersions) {
- this.managedInformerEventSource = managedInformerEventSource;
- this.parseResourceVersions = parseResourceVersions;
+ public enum EventHandling {
+ DEFER,
+ OBSOLETE,
+ NEW
}
- public synchronized void onDeleteEvent(T resource, boolean unknownState) {
- tombstones.add(resource.getMetadata().getUid());
- onEvent(resource, unknownState);
+ public TemporaryResourceCache(boolean comparableResourceVersions) {
+ this.comparableResourceVersions = comparableResourceVersions;
}
- public synchronized void onAddOrUpdateEvent(T resource) {
- onEvent(resource, false);
+ public synchronized void startEventFilteringModify(ResourceID resourceID) {
+ if (!comparableResourceVersions) {
+ return;
+ }
+ var ed = activeUpdates.computeIfAbsent(resourceID, id -> new EventFilterDetails());
+ ed.increaseActiveUpdates();
}
- synchronized void onEvent(T resource, boolean unknownState) {
- cache.computeIfPresent(
- ResourceID.fromResource(resource),
- (id, cached) ->
- (unknownState || !isLaterResourceVersion(id, cached, resource)) ? null : cached);
+ public synchronized Optional doneEventFilterModify(
+ ResourceID resourceID, String updatedResourceVersion) {
+ if (!comparableResourceVersions) {
+ return Optional.empty();
+ }
+ var ed = activeUpdates.get(resourceID);
+ if (ed.decreaseActiveUpdates()) {
+ activeUpdates.remove(resourceID);
+ return ed.getLatestEventAfterLastUpdateEvent(updatedResourceVersion);
+ } else {
+ return Optional.empty();
+ }
}
- public synchronized void putAddedResource(T newResource) {
- putResource(newResource, null);
+ public void onDeleteEvent(T resource, boolean unknownState) {
+ onEvent(ResourceAction.DELETED, resource, null, unknownState, true);
}
/**
- * put the item into the cache if the previousResourceVersion matches the current state. If not
- * the currently cached item is removed.
- *
- * @param previousResourceVersion null indicates an add
+ * @return true if the resourceVersion was obsolete
*/
- public synchronized void putResource(T newResource, String previousResourceVersion) {
- var resourceId = ResourceID.fromResource(newResource);
- var cachedResource = managedInformerEventSource.get(resourceId).orElse(null);
-
- boolean moveAhead = false;
- if (previousResourceVersion == null && cachedResource == null) {
- if (tombstones.contains(newResource.getMetadata().getUid())) {
- log.debug(
- "Won't resurrect uid {} for resource id: {}",
- newResource.getMetadata().getUid(),
- resourceId);
- return;
+ public EventHandling onAddOrUpdateEvent(
+ ResourceAction action, T resource, T prevResourceVersion) {
+ return onEvent(action, resource, prevResourceVersion, false, false);
+ }
+
+ private synchronized EventHandling onEvent(
+ ResourceAction action,
+ T resource,
+ T prevResourceVersion,
+ boolean unknownState,
+ boolean delete) {
+ if (!comparableResourceVersions) {
+ return EventHandling.NEW;
+ }
+
+ var resourceId = ResourceID.fromResource(resource);
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "Processing event for resource id: {} version: {} ",
+ resourceId,
+ resource.getMetadata().getResourceVersion());
+ }
+ if (!unknownState) {
+ latestResourceVersion = resource.getMetadata().getResourceVersion();
+ }
+ var cached = cache.get(resourceId);
+ EventHandling result = EventHandling.NEW;
+ if (cached != null) {
+ int comp = ReconcilerUtilsInternal.compareResourceVersions(resource, cached);
+ if (comp >= 0 || unknownState) {
+ cache.remove(resourceId);
+ // we propagate event only for our update or newer other can be discarded since we know we
+ // will receive
+ // additional event
+ result = comp == 0 ? EventHandling.OBSOLETE : EventHandling.NEW;
+ } else {
+ result = EventHandling.OBSOLETE;
}
- // we can skip further checks as this is a simple add and there's no previous entry to
- // consider
- moveAhead = true;
+ }
+ var ed = activeUpdates.get(resourceId);
+ if (ed != null && result != EventHandling.OBSOLETE) {
+ ed.setLastEvent(
+ delete
+ ? new ResourceDeleteEvent(ResourceAction.DELETED, resourceId, resource, unknownState)
+ : new ExtendedResourceEvent(action, resourceId, resource, prevResourceVersion));
+ return EventHandling.DEFER;
+ } else {
+ return result;
+ }
+ }
+
+ /** put the item into the cache if it's for a later state than what has already been observed. */
+ public synchronized void putResource(T newResource) {
+ if (!comparableResourceVersions) {
+ return;
+ }
+
+ var resourceId = ResourceID.fromResource(newResource);
+
+ if (newResource.getMetadata().getResourceVersion() == null) {
+ log.warn(
+ "Resource {}: with no resourceVersion put in temporary cache. This is not the expected"
+ + " usage pattern, only resources returned from the api server should be put in the"
+ + " cache.",
+ resourceId);
+ return;
}
- if (moveAhead
- || (cachedResource != null
- && (cachedResource
- .getMetadata()
- .getResourceVersion()
- .equals(previousResourceVersion))
- || isLaterResourceVersion(resourceId, newResource, cachedResource))) {
+ // check against the latestResourceVersion processed by the TemporaryResourceCache
+ // If the resource is older, then we can safely ignore.
+ //
+ // this also prevents resurrecting recently deleted entities for which the delete event
+ // has already been processed
+ if (latestResourceVersion != null
+ && ReconcilerUtilsInternal.compareResourceVersions(
+ latestResourceVersion, newResource.getMetadata().getResourceVersion())
+ > 0) {
log.debug(
- "Temporarily moving ahead to target version {} for resource id: {}",
+ "Resource {}: resourceVersion {} is not later than latest {}",
+ resourceId,
newResource.getMetadata().getResourceVersion(),
- resourceId);
- cache.put(resourceId, newResource);
- } else if (cache.remove(resourceId) != null) {
- log.debug("Removed an obsolete resource from cache for id: {}", resourceId);
+ latestResourceVersion);
+ return;
}
- }
- /**
- * @return true if {@link ConfigurationService#parseResourceVersionsForEventFilteringAndCaching()}
- * is enabled and the resourceVersion of newResource is numerically greater than
- * cachedResource, otherwise false
- */
- public boolean isLaterResourceVersion(ResourceID resourceId, T newResource, T cachedResource) {
- try {
- if (parseResourceVersions
- && Long.parseLong(newResource.getMetadata().getResourceVersion())
- > Long.parseLong(cachedResource.getMetadata().getResourceVersion())) {
- return true;
- }
- } catch (NumberFormatException e) {
+ // also make sure that we're later than the existing temporary entry
+ var cachedResource = getResourceFromCache(resourceId).orElse(null);
+
+ if (cachedResource == null
+ || ReconcilerUtilsInternal.compareResourceVersions(newResource, cachedResource) > 0) {
log.debug(
- "Could not compare resourceVersions {} and {} for {}",
+ "Temporarily moving ahead to target version {} for resource id: {}",
newResource.getMetadata().getResourceVersion(),
- cachedResource.getMetadata().getResourceVersion(),
resourceId);
+ cache.put(resourceId, newResource);
}
- return false;
}
public synchronized Optional getResourceFromCache(ResourceID resourceID) {
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java
index 2530c661ab..eae9663fe6 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java
@@ -25,6 +25,7 @@
import org.slf4j.LoggerFactory;
import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.api.reconciler.BaseControl;
import io.javaoperatorsdk.operator.health.Status;
import io.javaoperatorsdk.operator.processing.event.Event;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
@@ -62,8 +63,12 @@ public void scheduleOnce(ResourceID resourceID, long delay) {
cancelOnceSchedule(resourceID);
}
EventProducerTimeTask task = new EventProducerTimeTask(resourceID);
- onceTasks.put(resourceID, task);
- timer.schedule(task, delay);
+ if (delay == BaseControl.INSTANT_RESCHEDULE) {
+ task.run();
+ } else {
+ onceTasks.put(resourceID, task);
+ timer.schedule(task, delay);
+ }
}
@Override
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java
index c87c986f99..e5dae6ca80 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorIT.java
@@ -45,7 +45,7 @@ void shouldBePossibleToRetrieveNumberOfRegisteredControllers() {
void shouldBePossibleToRetrieveRegisteredControllerByName() {
final var operator = new Operator();
final var reconciler = new FooReconciler();
- final var name = ReconcilerUtils.getNameFor(reconciler);
+ final var name = ReconcilerUtilsInternal.getNameFor(reconciler);
var registeredControllers = operator.getRegisteredControllers();
assertTrue(operator.getRegisteredController(name).isEmpty());
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java
new file mode 100644
index 0000000000..129351e8af
--- /dev/null
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java
@@ -0,0 +1,321 @@
+/*
+ * 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;
+
+import java.net.URI;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.fabric8.kubernetes.api.model.*;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+import io.fabric8.kubernetes.api.model.apps.DeploymentSpec;
+import io.fabric8.kubernetes.client.CustomResource;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.http.HttpRequest;
+import io.fabric8.kubernetes.model.annotation.Group;
+import io.fabric8.kubernetes.model.annotation.ShortNames;
+import io.fabric8.kubernetes.model.annotation.Version;
+import io.javaoperatorsdk.operator.api.reconciler.NonComparableResourceVersionException;
+import io.javaoperatorsdk.operator.sample.simple.TestCustomReconciler;
+import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
+
+import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultFinalizerName;
+import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultNameFor;
+import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.getDefaultReconcilerName;
+import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.handleKubernetesClientException;
+import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.isFinalizerValid;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ReconcilerUtilsInternalTest {
+ private static final Logger log = LoggerFactory.getLogger(ReconcilerUtilsInternalTest.class);
+ public static final String RESOURCE_URI =
+ "https://kubernetes.docker.internal:6443/apis/tomcatoperator.io/v1/tomcats";
+
+ @Test
+ void defaultReconcilerNameShouldWork() {
+ assertEquals(
+ "testcustomreconciler",
+ getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName()));
+ assertEquals(
+ getDefaultNameFor(TestCustomReconciler.class),
+ getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName()));
+ assertEquals(
+ getDefaultNameFor(TestCustomReconciler.class),
+ getDefaultReconcilerName(TestCustomReconciler.class.getSimpleName()));
+ }
+
+ @Test
+ void defaultFinalizerShouldWork() {
+ assertTrue(isFinalizerValid(getDefaultFinalizerName(Pod.class)));
+ assertTrue(isFinalizerValid(getDefaultFinalizerName(TestCustomResource.class)));
+ }
+
+ @Test
+ void equalsSpecObject() {
+ var d1 = createTestDeployment();
+ var d2 = createTestDeployment();
+
+ assertThat(ReconcilerUtilsInternal.specsEqual(d1, d2)).isTrue();
+ }
+
+ @Test
+ void equalArbitraryDifferentSpecsOfObjects() {
+ var d1 = createTestDeployment();
+ var d2 = createTestDeployment();
+ d2.getSpec().getTemplate().getSpec().setHostname("otherhost");
+
+ assertThat(ReconcilerUtilsInternal.specsEqual(d1, d2)).isFalse();
+ }
+
+ @Test
+ void getsSpecWithReflection() {
+ Deployment deployment = new Deployment();
+ deployment.setSpec(new DeploymentSpec());
+ deployment.getSpec().setReplicas(5);
+
+ DeploymentSpec spec = (DeploymentSpec) ReconcilerUtilsInternal.getSpec(deployment);
+ assertThat(spec.getReplicas()).isEqualTo(5);
+ }
+
+ @Test
+ void properlyHandlesNullSpec() {
+ Namespace ns = new Namespace();
+
+ final var spec = ReconcilerUtilsInternal.getSpec(ns);
+ assertThat(spec).isNull();
+
+ ReconcilerUtilsInternal.setSpec(ns, null);
+ }
+
+ @Test
+ void setsSpecWithReflection() {
+ Deployment deployment = new Deployment();
+ deployment.setSpec(new DeploymentSpec());
+ deployment.getSpec().setReplicas(5);
+ DeploymentSpec newSpec = new DeploymentSpec();
+ newSpec.setReplicas(1);
+
+ ReconcilerUtilsInternal.setSpec(deployment, newSpec);
+
+ assertThat(deployment.getSpec().getReplicas()).isEqualTo(1);
+ }
+
+ @Test
+ void setsSpecCustomResourceWithReflection() {
+ Tomcat tomcat = new Tomcat();
+ tomcat.setSpec(new TomcatSpec());
+ tomcat.getSpec().setReplicas(5);
+ TomcatSpec newSpec = new TomcatSpec();
+ newSpec.setReplicas(1);
+
+ ReconcilerUtilsInternal.setSpec(tomcat, newSpec);
+
+ assertThat(tomcat.getSpec().getReplicas()).isEqualTo(1);
+ }
+
+ @Test
+ void loadYamlAsBuilder() {
+ DeploymentBuilder builder =
+ ReconcilerUtilsInternal.loadYaml(DeploymentBuilder.class, getClass(), "deployment.yaml");
+ builder.accept(ContainerBuilder.class, c -> c.withImage("my-image"));
+
+ Deployment deployment = builder.editMetadata().withName("my-deployment").and().build();
+ assertThat(deployment.getMetadata().getName()).isEqualTo("my-deployment");
+ }
+
+ private Deployment createTestDeployment() {
+ Deployment deployment = new Deployment();
+ deployment.setSpec(new DeploymentSpec());
+ deployment.getSpec().setReplicas(5);
+ PodTemplateSpec podTemplateSpec = new PodTemplateSpec();
+ deployment.getSpec().setTemplate(podTemplateSpec);
+ podTemplateSpec.setSpec(new PodSpec());
+ podTemplateSpec.getSpec().setHostname("localhost");
+ return deployment;
+ }
+
+ @Test
+ void handleKubernetesExceptionShouldThrowMissingCRDExceptionWhenAppropriate() {
+ var request = mock(HttpRequest.class);
+ when(request.uri()).thenReturn(URI.create(RESOURCE_URI));
+ assertThrows(
+ MissingCRDException.class,
+ () ->
+ handleKubernetesClientException(
+ new KubernetesClientException(
+ "Failure executing: GET at: " + RESOURCE_URI + ". Message: Not Found.",
+ null,
+ 404,
+ null,
+ request),
+ HasMetadata.getFullResourceName(Tomcat.class)));
+ }
+
+ @Group("tomcatoperator.io")
+ @Version("v1")
+ @ShortNames("tc")
+ private static class Tomcat extends CustomResource implements Namespaced {}
+
+ private static class TomcatSpec {
+ private Integer replicas;
+
+ public Integer getReplicas() {
+ return replicas;
+ }
+
+ public void setReplicas(Integer replicas) {
+ this.replicas = replicas;
+ }
+ }
+
+ // naive performance test that compares the work case scenario for the parsing and non-parsing
+ // variants
+ @Test
+ @Disabled
+ public void compareResourcePerformanceTest() {
+ var execNum = 30000000;
+ var startTime = System.currentTimeMillis();
+ for (int i = 0; i < execNum; i++) {
+ var res = ReconcilerUtilsInternal.compareResourceVersions("123456788" + i, "123456789" + i);
+ }
+ var dur1 = System.currentTimeMillis() - startTime;
+ log.info("Duration without parsing: {}", dur1);
+ startTime = System.currentTimeMillis();
+ for (int i = 0; i < execNum; i++) {
+ var res = Long.parseLong("123456788" + i) > Long.parseLong("123456789" + i);
+ }
+ var dur2 = System.currentTimeMillis() - startTime;
+ log.info("Duration with parsing: {}", dur2);
+
+ assertThat(dur1).isLessThan(dur2);
+ }
+
+ @Test
+ void validateAndCompareResourceVersionsTest() {
+ assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", "22")).isNegative();
+ assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("22", "11")).isPositive();
+ assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("1", "1")).isZero();
+ assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", "11")).isZero();
+ assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("123", "2")).isPositive();
+ assertThat(ReconcilerUtilsInternal.validateAndCompareResourceVersions("3", "211")).isNegative();
+
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("aa", "22"));
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", "ba"));
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("", "22"));
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("11", ""));
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("01", "123"));
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("123", "01"));
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("3213", "123a"));
+ assertThrows(
+ NonComparableResourceVersionException.class,
+ () -> ReconcilerUtilsInternal.validateAndCompareResourceVersions("321", "123a"));
+ }
+
+ @Test
+ void compareResourceVersionsWithStrings() {
+ // Test equal versions
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("1", "1")).isZero();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("123", "123")).isZero();
+
+ // Test different lengths - shorter version is less than longer version
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("1", "12")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("12", "1")).isPositive();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("99", "100")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("100", "99")).isPositive();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("9", "100")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("100", "9")).isPositive();
+
+ // Test same length - lexicographic comparison
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("1", "2")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("2", "1")).isPositive();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("11", "12")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("12", "11")).isPositive();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("99", "100")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("100", "99")).isPositive();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("123", "124")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("124", "123")).isPositive();
+
+ // Test with non-numeric strings (algorithm should still work character-wise)
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("a", "b")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("b", "a")).isPositive();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("abc", "abd")).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("abd", "abc")).isPositive();
+
+ // Test edge cases with larger numbers
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("1234567890", "1234567891"))
+ .isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions("1234567891", "1234567890"))
+ .isPositive();
+ }
+
+ @Test
+ void compareResourceVersionsWithHasMetadata() {
+ // Test equal versions
+ HasMetadata resource1 = createResourceWithVersion("123");
+ HasMetadata resource2 = createResourceWithVersion("123");
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isZero();
+
+ // Test different lengths
+ resource1 = createResourceWithVersion("1");
+ resource2 = createResourceWithVersion("12");
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive();
+
+ // Test same length, different values
+ resource1 = createResourceWithVersion("100");
+ resource2 = createResourceWithVersion("200");
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive();
+
+ // Test realistic Kubernetes resource versions
+ resource1 = createResourceWithVersion("12345");
+ resource2 = createResourceWithVersion("12346");
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource1, resource2)).isNegative();
+ assertThat(ReconcilerUtilsInternal.compareResourceVersions(resource2, resource1)).isPositive();
+ }
+
+ private HasMetadata createResourceWithVersion(String resourceVersion) {
+ return new PodBuilder()
+ .withMetadata(
+ new ObjectMetaBuilder()
+ .withName("test-pod")
+ .withNamespace("default")
+ .withResourceVersion(resourceVersion)
+ .build())
+ .build();
+ }
+}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java
deleted file mode 100644
index 3bbe2a894b..0000000000
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java
+++ /dev/null
@@ -1,186 +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;
-
-import java.net.URI;
-
-import org.junit.jupiter.api.Test;
-
-import io.fabric8.kubernetes.api.model.*;
-import io.fabric8.kubernetes.api.model.apps.Deployment;
-import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
-import io.fabric8.kubernetes.api.model.apps.DeploymentSpec;
-import io.fabric8.kubernetes.client.CustomResource;
-import io.fabric8.kubernetes.client.KubernetesClientException;
-import io.fabric8.kubernetes.client.http.HttpRequest;
-import io.fabric8.kubernetes.model.annotation.Group;
-import io.fabric8.kubernetes.model.annotation.ShortNames;
-import io.fabric8.kubernetes.model.annotation.Version;
-import io.javaoperatorsdk.operator.sample.simple.TestCustomReconciler;
-import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
-
-import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultFinalizerName;
-import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultNameFor;
-import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultReconcilerName;
-import static io.javaoperatorsdk.operator.ReconcilerUtils.handleKubernetesClientException;
-import static io.javaoperatorsdk.operator.ReconcilerUtils.isFinalizerValid;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-class ReconcilerUtilsTest {
-
- public static final String RESOURCE_URI =
- "https://kubernetes.docker.internal:6443/apis/tomcatoperator.io/v1/tomcats";
-
- @Test
- void defaultReconcilerNameShouldWork() {
- assertEquals(
- "testcustomreconciler",
- getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName()));
- assertEquals(
- getDefaultNameFor(TestCustomReconciler.class),
- getDefaultReconcilerName(TestCustomReconciler.class.getCanonicalName()));
- assertEquals(
- getDefaultNameFor(TestCustomReconciler.class),
- getDefaultReconcilerName(TestCustomReconciler.class.getSimpleName()));
- }
-
- @Test
- void defaultFinalizerShouldWork() {
- assertTrue(isFinalizerValid(getDefaultFinalizerName(Pod.class)));
- assertTrue(isFinalizerValid(getDefaultFinalizerName(TestCustomResource.class)));
- }
-
- @Test
- void equalsSpecObject() {
- var d1 = createTestDeployment();
- var d2 = createTestDeployment();
-
- assertThat(ReconcilerUtils.specsEqual(d1, d2)).isTrue();
- }
-
- @Test
- void equalArbitraryDifferentSpecsOfObjects() {
- var d1 = createTestDeployment();
- var d2 = createTestDeployment();
- d2.getSpec().getTemplate().getSpec().setHostname("otherhost");
-
- assertThat(ReconcilerUtils.specsEqual(d1, d2)).isFalse();
- }
-
- @Test
- void getsSpecWithReflection() {
- Deployment deployment = new Deployment();
- deployment.setSpec(new DeploymentSpec());
- deployment.getSpec().setReplicas(5);
-
- DeploymentSpec spec = (DeploymentSpec) ReconcilerUtils.getSpec(deployment);
- assertThat(spec.getReplicas()).isEqualTo(5);
- }
-
- @Test
- void properlyHandlesNullSpec() {
- Namespace ns = new Namespace();
-
- final var spec = ReconcilerUtils.getSpec(ns);
- assertThat(spec).isNull();
-
- ReconcilerUtils.setSpec(ns, null);
- }
-
- @Test
- void setsSpecWithReflection() {
- Deployment deployment = new Deployment();
- deployment.setSpec(new DeploymentSpec());
- deployment.getSpec().setReplicas(5);
- DeploymentSpec newSpec = new DeploymentSpec();
- newSpec.setReplicas(1);
-
- ReconcilerUtils.setSpec(deployment, newSpec);
-
- assertThat(deployment.getSpec().getReplicas()).isEqualTo(1);
- }
-
- @Test
- void setsSpecCustomResourceWithReflection() {
- Tomcat tomcat = new Tomcat();
- tomcat.setSpec(new TomcatSpec());
- tomcat.getSpec().setReplicas(5);
- TomcatSpec newSpec = new TomcatSpec();
- newSpec.setReplicas(1);
-
- ReconcilerUtils.setSpec(tomcat, newSpec);
-
- assertThat(tomcat.getSpec().getReplicas()).isEqualTo(1);
- }
-
- @Test
- void loadYamlAsBuilder() {
- DeploymentBuilder builder =
- ReconcilerUtils.loadYaml(DeploymentBuilder.class, getClass(), "deployment.yaml");
- builder.accept(ContainerBuilder.class, c -> c.withImage("my-image"));
-
- Deployment deployment = builder.editMetadata().withName("my-deployment").and().build();
- assertThat(deployment.getMetadata().getName()).isEqualTo("my-deployment");
- }
-
- private Deployment createTestDeployment() {
- Deployment deployment = new Deployment();
- deployment.setSpec(new DeploymentSpec());
- deployment.getSpec().setReplicas(5);
- PodTemplateSpec podTemplateSpec = new PodTemplateSpec();
- deployment.getSpec().setTemplate(podTemplateSpec);
- podTemplateSpec.setSpec(new PodSpec());
- podTemplateSpec.getSpec().setHostname("localhost");
- return deployment;
- }
-
- @Test
- void handleKubernetesExceptionShouldThrowMissingCRDExceptionWhenAppropriate() {
- var request = mock(HttpRequest.class);
- when(request.uri()).thenReturn(URI.create(RESOURCE_URI));
- assertThrows(
- MissingCRDException.class,
- () ->
- handleKubernetesClientException(
- new KubernetesClientException(
- "Failure executing: GET at: " + RESOURCE_URI + ". Message: Not Found.",
- null,
- 404,
- null,
- request),
- HasMetadata.getFullResourceName(Tomcat.class)));
- }
-
- @Group("tomcatoperator.io")
- @Version("v1")
- @ShortNames("tc")
- private static class Tomcat extends CustomResource implements Namespaced {}
-
- private static class TomcatSpec {
- private Integer replicas;
-
- public Integer getReplicas() {
- return replicas;
- }
-
- public void setReplicas(Integer replicas) {
- this.replicas = replicas;
- }
- }
-}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java
index 956b3d9475..24e36cbe33 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java
@@ -32,6 +32,10 @@ public static TestCustomResource testCustomResource() {
return testCustomResource(new ResourceID(UUID.randomUUID().toString(), "test"));
}
+ public static TestCustomResource testCustomResource1() {
+ return testCustomResource(new ResourceID("test1", "default"));
+ }
+
public static CustomResourceDefinition testCRD(String scope) {
return new CustomResourceDefinitionBuilder()
.editOrNewSpec()
@@ -43,10 +47,6 @@ public static CustomResourceDefinition testCRD(String scope) {
.build();
}
- public static TestCustomResource testCustomResource1() {
- return testCustomResource(new ResourceID("test1", "default"));
- }
-
public static ResourceID testCustomResource1Id() {
return new ResourceID("test1", "default");
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java
index 064c73c7f9..4df8df385b 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java
@@ -15,13 +15,23 @@
*/
package io.javaoperatorsdk.operator.api.reconciler;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Pod;
+import io.fabric8.kubernetes.api.model.PodBuilder;
import io.fabric8.kubernetes.api.model.Secret;
import io.javaoperatorsdk.operator.processing.Controller;
import io.javaoperatorsdk.operator.processing.event.EventSourceManager;
import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException;
+import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.EventSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -30,17 +40,21 @@
class DefaultContextTest {
- private final Secret primary = new Secret();
- private final Controller mockController = mock();
+ private DefaultContext> context;
+ private Controller mockController;
+ private EventSourceManager mockManager;
- private final DefaultContext> context =
- new DefaultContext<>(null, mockController, primary, false, false);
+ @BeforeEach
+ void setUp() {
+ mockController = mock();
+ mockManager = mock();
+ when(mockController.getEventSourceManager()).thenReturn(mockManager);
+
+ context = new DefaultContext<>(null, mockController, new Secret(), false, false);
+ }
@Test
- @SuppressWarnings("unchecked")
void getSecondaryResourceReturnsEmptyOptionalOnNonActivatedDRType() {
- var mockManager = mock(EventSourceManager.class);
- when(mockController.getEventSourceManager()).thenReturn(mockManager);
when(mockController.workflowContainsDependentForType(ConfigMap.class)).thenReturn(true);
when(mockManager.getEventSourceFor(any(), any()))
.thenThrow(new NoEventSourceForClassException(ConfigMap.class));
@@ -56,4 +70,101 @@ void setRetryInfo() {
assertThat(newContext).isSameAs(context);
assertThat(newContext.getRetryInfo()).hasValue(retryInfo);
}
+
+ @Test
+ void latestDistinctKeepsOnlyLatestResourceVersion() {
+ // Create multiple resources with same name and namespace but different versions
+ var pod1v1 = podWithNameAndVersion("pod1", "100");
+ var pod1v2 = podWithNameAndVersion("pod1", "200");
+ var pod1v3 = podWithNameAndVersion("pod1", "150");
+
+ // Create a resource with different name
+ var pod2v1 = podWithNameAndVersion("pod2", "100");
+
+ // Create a resource with same name but different namespace
+ var pod1OtherNsv1 = podWithNameAndVersion("pod1", "50", "other");
+
+ setUpEventSourceWith(pod1v1, pod1v2, pod1v3, pod1OtherNsv1, pod2v1);
+
+ var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList();
+
+ // Should have 3 resources: pod1 in default (latest version 200), pod2 in default, and pod1 in
+ // other
+ assertThat(result).hasSize(3);
+
+ // Find pod1 in default namespace - should have version 200
+ final var pod1InDefault =
+ result.stream()
+ .filter(r -> ResourceID.fromResource(r).isSameResource("pod1", "default"))
+ .findFirst()
+ .orElseThrow();
+ assertThat(pod1InDefault.getMetadata().getResourceVersion()).isEqualTo("200");
+
+ // Find pod2 in default namespace - should exist
+ HasMetadata pod2InDefault =
+ result.stream()
+ .filter(r -> ResourceID.fromResource(r).isSameResource("pod2", "default"))
+ .findFirst()
+ .orElseThrow();
+ assertThat(pod2InDefault.getMetadata().getResourceVersion()).isEqualTo("100");
+
+ // Find pod1 in other namespace - should exist
+ HasMetadata pod1InOther =
+ result.stream()
+ .filter(r -> ResourceID.fromResource(r).isSameResource("pod1", "other"))
+ .findFirst()
+ .orElseThrow();
+ assertThat(pod1InOther.getMetadata().getResourceVersion()).isEqualTo("50");
+ }
+
+ private void setUpEventSourceWith(Pod... pods) {
+ EventSource mockEventSource = mock();
+ when(mockEventSource.getSecondaryResources(any())).thenReturn(Set.of(pods));
+ when(mockManager.getEventSourcesFor(Pod.class)).thenReturn(List.of(mockEventSource));
+ }
+
+ private static Pod podWithNameAndVersion(
+ String name, String resourceVersion, String... namespace) {
+ final var ns = namespace != null && namespace.length > 0 ? namespace[0] : "default";
+ return new PodBuilder()
+ .withMetadata(
+ new ObjectMetaBuilder()
+ .withName(name)
+ .withNamespace(ns)
+ .withResourceVersion(resourceVersion)
+ .build())
+ .build();
+ }
+
+ @Test
+ void latestDistinctHandlesEmptyStream() {
+ var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList();
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void latestDistinctHandlesSingleResource() {
+ final var pod = podWithNameAndVersion("pod1", "100");
+ setUpEventSourceWith(pod);
+
+ var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList();
+
+ assertThat(result).hasSize(1);
+ assertThat(result).contains(pod);
+ }
+
+ @Test
+ void latestDistinctComparesNumericVersionsCorrectly() {
+ // Test that version 1000 is greater than version 999 (not lexicographic)
+ final var podV999 = podWithNameAndVersion("pod1", "999");
+ final var podV1000 = podWithNameAndVersion("pod1", "1000");
+ setUpEventSourceWith(podV999, podV1000);
+
+ var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList();
+
+ assertThat(result).hasSize(1);
+ HasMetadata resultPod = result.iterator().next();
+ assertThat(resultPod.getMetadata().getResourceVersion()).isEqualTo("1000");
+ }
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java
index 235dd3cd40..c878a4fc06 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java
@@ -19,6 +19,7 @@
import java.util.function.UnaryOperator;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -39,6 +40,7 @@
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
import static io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils.DEFAULT_MAX_RETRY;
+import static io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils.compareResourceVersions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
@@ -180,4 +182,53 @@ void cachePollTimeouts() {
10L));
assertThat(ex.getMessage()).contains("Timeout");
}
+
+ @Test
+ public void compareResourceVersionsTest() {
+ assertThat(compareResourceVersions("11", "22")).isNegative();
+ assertThat(compareResourceVersions("22", "11")).isPositive();
+ assertThat(compareResourceVersions("1", "1")).isZero();
+ assertThat(compareResourceVersions("11", "11")).isZero();
+ assertThat(compareResourceVersions("123", "2")).isPositive();
+ assertThat(compareResourceVersions("3", "211")).isNegative();
+
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("aa", "22"));
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("11", "ba"));
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("", "22"));
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("11", ""));
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("01", "123"));
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("123", "01"));
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("3213", "123a"));
+ assertThrows(
+ NonComparableResourceVersionException.class, () -> compareResourceVersions("321", "123a"));
+ }
+
+ // naive performance test that compares the work case scenario for the parsing and non-parsing
+ // variants
+ @Test
+ @Disabled
+ public void compareResourcePerformanceTest() {
+ var execNum = 30000000;
+ var startTime = System.currentTimeMillis();
+ for (int i = 0; i < execNum; i++) {
+ var res = compareResourceVersions("123456788", "123456789");
+ }
+ var dur1 = System.currentTimeMillis() - startTime;
+ log.info("Duration without parsing: {}", dur1);
+ startTime = System.currentTimeMillis();
+ for (int i = 0; i < execNum; i++) {
+ var res = Long.parseLong("123456788") > Long.parseLong("123456789");
+ }
+ var dur2 = System.currentTimeMillis() - startTime;
+ log.info("Duration with parsing: {}", dur2);
+
+ assertThat(dur1).isLessThan(dur2);
+ }
}
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..8d0176cd4a
--- /dev/null
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java
@@ -0,0 +1,327 @@
+/*
+ * 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.*;
+
+@SuppressWarnings("unchecked")
+class ResourceOperationsTest {
+
+ private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer";
+
+ private Context context;
+
+ @SuppressWarnings("rawtypes")
+ private Resource resourceOp;
+
+ private ControllerEventSource controllerEventSource;
+ private ResourceOperations resourceOperations;
+
+ @BeforeEach
+ void setupMocks() {
+ context = mock(Context.class);
+ final var client = mock(KubernetesClient.class);
+ final var mixedOperation = mock(MixedOperation.class);
+ resourceOp = mock(Resource.class);
+ controllerEventSource = mock(ControllerEventSource.class);
+ final var 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(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(resource, UnaryOperator.identity()));
+
+ assertThat(exception.getMessage()).contains("No event source found for type");
+ }
+
+ @Test
+ void resourcePatchUsesFirstEventSourceIfMultipleEventSourcesPresent() {
+ 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));
+
+ resourceOperations.resourcePatch(resource, UnaryOperator.identity());
+
+ verify(eventSource1, times(1))
+ .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class));
+ }
+
+ @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(resource, UnaryOperator.identity()));
+
+ assertThat(exception.getMessage()).contains("Target event source must be a subclass off");
+ assertThat(exception.getMessage()).contains("ManagedInformerEventSource");
+ }
+}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java
index bb9d6cf71e..1db69a1f9e 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java
@@ -21,8 +21,10 @@
import org.junit.jupiter.api.Test;
import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.DefaultContext;
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
import static org.junit.jupiter.api.Assertions.*;
@@ -31,6 +33,13 @@
class AbstractDependentResourceTest {
+ private static final TestCustomResource PRIMARY = new TestCustomResource();
+ private static final DefaultContext CONTEXT = createContext(PRIMARY);
+
+ private static DefaultContext createContext(TestCustomResource primary) {
+ return new DefaultContext<>(mock(), mock(), primary, false, false);
+ }
+
@Test
void throwsExceptionIfDesiredIsNullOnCreate() {
TestDependentResource testDependentResource = new TestDependentResource();
@@ -38,8 +47,7 @@ void throwsExceptionIfDesiredIsNullOnCreate() {
testDependentResource.setDesired(null);
assertThrows(
- DependentResourceException.class,
- () -> testDependentResource.reconcile(new TestCustomResource(), null));
+ DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT));
}
@Test
@@ -49,8 +57,7 @@ void throwsExceptionIfDesiredIsNullOnUpdate() {
testDependentResource.setDesired(null);
assertThrows(
- DependentResourceException.class,
- () -> testDependentResource.reconcile(new TestCustomResource(), null));
+ DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT));
}
@Test
@@ -60,8 +67,7 @@ void throwsExceptionIfCreateReturnsNull() {
testDependentResource.setDesired(configMap());
assertThrows(
- DependentResourceException.class,
- () -> testDependentResource.reconcile(new TestCustomResource(), null));
+ DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT));
}
@Test
@@ -71,8 +77,28 @@ void throwsExceptionIfUpdateReturnsNull() {
testDependentResource.setDesired(configMap());
assertThrows(
- DependentResourceException.class,
- () -> testDependentResource.reconcile(new TestCustomResource(), null));
+ DependentResourceException.class, () -> testDependentResource.reconcile(PRIMARY, CONTEXT));
+ }
+
+ @Test
+ void checkThatDesiredIsOnlyCalledOnce() {
+ final var testDependentResource = new DesiredCallCountCheckingDR();
+ final var primary = new TestCustomResource();
+ final var spec = primary.getSpec();
+ spec.setConfigMapName("foo");
+ spec.setKey("key");
+ spec.setValue("value");
+ final var context = createContext(primary);
+ testDependentResource.reconcile(primary, context);
+
+ spec.setValue("value2");
+ testDependentResource.reconcile(primary, context);
+
+ assertEquals(1, testDependentResource.desiredCallCount);
+
+ context.getOrComputeDesiredStateFor(
+ testDependentResource, p -> testDependentResource.desired(p, context));
+ assertEquals(1, testDependentResource.desiredCallCount);
}
private ConfigMap configMap() {
@@ -130,22 +156,12 @@ protected ConfigMap desired(TestCustomResource primary, Context match(
return result;
}
}
+
+ private static class DesiredCallCountCheckingDR extends TestDependentResource {
+ private short desiredCallCount;
+
+ @Override
+ public ConfigMap update(
+ ConfigMap actual,
+ ConfigMap desired,
+ TestCustomResource primary,
+ Context context) {
+ return desired;
+ }
+
+ @Override
+ public ConfigMap create(
+ ConfigMap desired, TestCustomResource primary, Context context) {
+ return desired;
+ }
+
+ @Override
+ protected ConfigMap desired(TestCustomResource primary, Context context) {
+ final var spec = primary.getSpec();
+ desiredCallCount++;
+ return new ConfigMapBuilder()
+ .editOrNewMetadata()
+ .withName(spec.getConfigMapName())
+ .endMetadata()
+ .addToData(spec.getKey(), spec.getValue())
+ .build();
+ }
+ }
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java
index 495fe98416..8dd7283fb9 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java
@@ -18,37 +18,48 @@
import java.util.Map;
import java.util.Optional;
-import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import io.fabric8.kubernetes.api.model.*;
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
import io.fabric8.kubernetes.api.model.apps.DeploymentStatusBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.MockKubernetesClient;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.DefaultContext;
import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher.match;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
@SuppressWarnings({"unchecked"})
class GenericKubernetesResourceMatcherTest {
- private static final Context context = mock(Context.class);
+ private static final Context context = new TestContext();
+
+ private static class TestContext extends DefaultContext {
+ private final KubernetesClient client = MockKubernetesClient.client(HasMetadata.class);
+
+ public TestContext() {
+ this(null);
+ }
+
+ public TestContext(HasMetadata primary) {
+ super(mock(), mock(), primary, false, false);
+ }
+
+ @Override
+ public KubernetesClient getClient() {
+ return client;
+ }
+ }
Deployment actual = createDeployment();
Deployment desired = createDeployment();
TestDependentResource dependentResource = new TestDependentResource(desired);
- @BeforeAll
- static void setUp() {
- final var client = MockKubernetesClient.client(HasMetadata.class);
- when(context.getClient()).thenReturn(client);
- }
-
@Test
void matchesTrivialCases() {
assertThat(GenericKubernetesResourceMatcher.match(desired, actual, context).matched()).isTrue();
@@ -77,9 +88,10 @@ void matchesWithStrongSpecEquality() {
@Test
void doesNotMatchRemovedValues() {
actual = createDeployment();
+ final var localContext = new TestContext(createPrimary("removed"));
assertThat(
GenericKubernetesResourceMatcher.match(
- dependentResource.desired(createPrimary("removed"), null), actual, context)
+ dependentResource.getOrComputeDesired(localContext), actual, localContext)
.matched())
.withFailMessage("Removing values in metadata should lead to a mismatch")
.isFalse();
@@ -186,7 +198,7 @@ ConfigMap createConfigMap() {
}
Deployment createDeployment() {
- return ReconcilerUtils.loadYaml(
+ return ReconcilerUtilsInternal.loadYaml(
Deployment.class, GenericKubernetesResourceMatcherTest.class, "nginx-deployment.yaml");
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java
index 3b6580c5d3..70d664f652 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java
@@ -25,7 +25,7 @@
import io.fabric8.kubernetes.api.model.*;
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.javaoperatorsdk.operator.MockKubernetesClient;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Context;
@@ -131,7 +131,7 @@ void checkServiceAccount() {
}
Deployment createDeployment() {
- return ReconcilerUtils.loadYaml(
+ return ReconcilerUtilsInternal.loadYaml(
Deployment.class, GenericResourceUpdaterTest.class, "nginx-deployment.yaml");
}
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java
index bbcfa704b5..c4d2f2c77d 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java
@@ -32,7 +32,7 @@
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.javaoperatorsdk.operator.MockKubernetesClient;
import io.javaoperatorsdk.operator.OperatorException;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Context;
@@ -419,7 +419,7 @@ void testSortListItems() {
}
private static R loadResource(String fileName, Class clazz) {
- return ReconcilerUtils.loadYaml(
+ return ReconcilerUtilsInternal.loadYaml(
clazz, SSABasedGenericKubernetesResourceMatcherTest.class, fileName);
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java
index ac187d7eb9..bff9ef3dbd 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java
@@ -38,8 +38,8 @@
import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter;
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter;
import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter.RateLimitState;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource;
-import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent;
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent;
import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource;
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 cc9df317ae..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,12 +26,10 @@
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
-import org.mockito.stubbing.Answer;
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.client.CustomResource;
-import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.utils.KubernetesSerialization;
import io.javaoperatorsdk.operator.MockKubernetesClient;
import io.javaoperatorsdk.operator.OperatorException;
@@ -47,6 +45,7 @@
import io.javaoperatorsdk.operator.api.reconciler.DeleteControl;
import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl;
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;
@@ -56,10 +55,8 @@
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
import static io.javaoperatorsdk.operator.TestUtils.markForDeletion;
-import static io.javaoperatorsdk.operator.processing.event.ReconciliationDispatcher.MAX_UPDATE_RETRY;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.*;
@SuppressWarnings({"unchecked", "rawtypes"})
@@ -74,6 +71,7 @@ class ReconciliationDispatcherTest {
private final CustomResourceFacade customResourceFacade =
mock(ReconciliationDispatcher.CustomResourceFacade.class);
private static ConfigurationService configurationService;
+ private ResourceOperations mockResourceOperations;
@BeforeEach
void setup() {
@@ -153,29 +151,25 @@ public boolean useFinalizer() {
}
@Test
- void addFinalizerOnNewResource() {
+ void addFinalizerOnNewResource() throws Exception {
assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER));
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
+ reconciliationDispatcher.handleDispatch(
+ executionScopeWithCREvent(testCustomResource), createTestContext());
verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any());
- verify(customResourceFacade, times(1))
- .patchResourceWithSSA(
- argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER)));
+ verify(mockResourceOperations, times(1)).addFinalizerWithSSA();
}
@Test
- void addFinalizerOnNewResourceWithoutSSA() {
- initConfigService(false);
+ void addFinalizerOnNewResourceWithoutSSA() throws Exception {
+ initConfigService(false, false);
final ReconciliationDispatcher dispatcher =
init(testCustomResource, reconciler, null, customResourceFacade, true);
-
assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER));
- dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
+
+ dispatcher.handleDispatch(executionScopeWithCREvent(testCustomResource), createTestContext());
+
verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any());
- verify(customResourceFacade, times(1))
- .patchResource(
- argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER)),
- any());
- assertThat(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)).isTrue();
+ verify(mockResourceOperations, times(1)).addFinalizer();
}
@Test
@@ -190,13 +184,13 @@ void patchesBothResourceAndStatusIfFinalizerSet() {
testCustomResource.addFinalizer(DEFAULT_FINALIZER);
reconciler.reconcile = (r, c) -> UpdateControl.patchResourceAndStatus(testCustomResource);
- when(customResourceFacade.patchResource(eq(testCustomResource), any()))
+ when(customResourceFacade.patchResource(any(), eq(testCustomResource), any()))
.thenReturn(testCustomResource);
reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
- verify(customResourceFacade, times(1)).patchResource(eq(testCustomResource), any());
- verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any());
+ verify(customResourceFacade, times(1)).patchResource(any(), eq(testCustomResource), any());
+ verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any());
}
@Test
@@ -207,8 +201,8 @@ void patchesStatus() {
reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
- verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any());
- verify(customResourceFacade, never()).patchResource(any(), any());
+ verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any());
+ verify(customResourceFacade, never()).patchResource(any(), any(), any());
}
@Test
@@ -231,87 +225,16 @@ void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() {
}
@Test
- void removesDefaultFinalizerOnDeleteIfSet() {
+ void removesDefaultFinalizerOnDeleteIfSet() throws Exception {
testCustomResource.addFinalizer(DEFAULT_FINALIZER);
markForDeletion(testCustomResource);
var postExecControl =
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
+ reconciliationDispatcher.handleDispatch(
+ executionScopeWithCREvent(testCustomResource), createTestContext());
assertThat(postExecControl.isFinalizerRemoved()).isTrue();
- verify(customResourceFacade, times(1)).patchResourceWithoutSSA(eq(testCustomResource), any());
- }
-
- @Test
- void retriesFinalizerRemovalWithFreshResource() {
- testCustomResource.addFinalizer(DEFAULT_FINALIZER);
- markForDeletion(testCustomResource);
- var resourceWithFinalizer = TestUtils.testCustomResource();
- resourceWithFinalizer.addFinalizer(DEFAULT_FINALIZER);
- when(customResourceFacade.patchResourceWithoutSSA(eq(testCustomResource), any()))
- .thenThrow(new KubernetesClientException(null, 409, null))
- .thenReturn(testCustomResource);
- when(customResourceFacade.getResource(any(), any())).thenReturn(resourceWithFinalizer);
-
- var postExecControl =
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
-
- assertThat(postExecControl.isFinalizerRemoved()).isTrue();
- verify(customResourceFacade, times(2)).patchResourceWithoutSSA(any(), any());
- verify(customResourceFacade, times(1)).getResource(any(), any());
- }
-
- @Test
- void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() {
- // simulate the operator not able or not be allowed to get the custom resource during the retry
- // of the finalizer removal
- testCustomResource.addFinalizer(DEFAULT_FINALIZER);
- markForDeletion(testCustomResource);
- when(customResourceFacade.patchResourceWithoutSSA(any(), any()))
- .thenThrow(new KubernetesClientException(null, 409, null));
- when(customResourceFacade.getResource(any(), any())).thenReturn(null);
-
- var postExecControl =
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
-
- assertThat(postExecControl.isFinalizerRemoved()).isTrue();
- verify(customResourceFacade, times(1)).patchResourceWithoutSSA(eq(testCustomResource), any());
- verify(customResourceFacade, times(1)).getResource(any(), any());
- }
-
- @Test
- void throwsExceptionIfFinalizerRemovalRetryExceeded() {
- testCustomResource.addFinalizer(DEFAULT_FINALIZER);
- markForDeletion(testCustomResource);
- when(customResourceFacade.patchResourceWithoutSSA(any(), any()))
- .thenThrow(new KubernetesClientException(null, 409, null));
- when(customResourceFacade.getResource(any(), any()))
- .thenAnswer((Answer) invocationOnMock -> createResourceWithFinalizer());
-
- var postExecControl =
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
-
- assertThat(postExecControl.isFinalizerRemoved()).isFalse();
- assertThat(postExecControl.getRuntimeException()).isPresent();
- assertThat(postExecControl.getRuntimeException().get()).isInstanceOf(OperatorException.class);
- verify(customResourceFacade, times(MAX_UPDATE_RETRY)).patchResourceWithoutSSA(any(), any());
- verify(customResourceFacade, times(MAX_UPDATE_RETRY - 1)).getResource(any(), any());
- }
-
- @Test
- void throwsExceptionIfFinalizerRemovalClientExceptionIsNotConflict() {
- testCustomResource.addFinalizer(DEFAULT_FINALIZER);
- markForDeletion(testCustomResource);
- when(customResourceFacade.patchResourceWithoutSSA(any(), any()))
- .thenThrow(new KubernetesClientException(null, 400, null));
-
- var res =
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
-
- assertThat(res.getRuntimeException()).isPresent();
- assertThat(res.getRuntimeException().get()).isInstanceOf(KubernetesClientException.class);
- verify(customResourceFacade, times(1)).patchResourceWithoutSSA(any(), any());
- verify(customResourceFacade, never()).getResource(any(), any());
+ verify(mockResourceOperations, times(1)).removeFinalizer();
}
@Test
@@ -354,7 +277,7 @@ void doesNotRemovesTheSetFinalizerIfTheDeleteNotMethodInstructsIt() {
reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
assertEquals(1, testCustomResource.getMetadata().getFinalizers().size());
- verify(customResourceFacade, never()).patchResource(any(), any());
+ verify(customResourceFacade, never()).patchResource(any(), any(), any());
}
@Test
@@ -364,21 +287,24 @@ void doesNotUpdateTheResourceIfNoUpdateUpdateControlIfFinalizerSet() {
reconciler.reconcile = (r, c) -> UpdateControl.noUpdate();
reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
- verify(customResourceFacade, never()).patchResource(any(), any());
- verify(customResourceFacade, never()).patchStatus(eq(testCustomResource), any());
+ verify(customResourceFacade, never()).patchResource(any(), any(), any());
+ verify(customResourceFacade, never()).patchStatus(any(), eq(testCustomResource), any());
}
@Test
- void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() {
+ void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() throws Exception {
+
removeFinalizers(testCustomResource);
reconciler.reconcile = (r, c) -> UpdateControl.noUpdate();
- when(customResourceFacade.patchResourceWithSSA(any())).thenReturn(testCustomResource);
+ var context = createTestContext();
+ when(mockResourceOperations.addFinalizerWithSSA()).thenReturn(testCustomResource);
var postExecControl =
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
+ reconciliationDispatcher.handleDispatch(
+ executionScopeWithCREvent(testCustomResource), context);
+
+ verify(mockResourceOperations, times(1)).addFinalizerWithSSA();
- verify(customResourceFacade, times(1))
- .patchResourceWithSSA(argThat(a -> !a.getMetadata().getFinalizers().isEmpty()));
assertThat(postExecControl.updateIsStatusPatch()).isFalse();
assertThat(postExecControl.getUpdatedCustomResource()).isPresent();
}
@@ -390,7 +316,7 @@ void doesNotCallDeleteIfMarkedForDeletionButNotOurFinalizer() {
reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
- verify(customResourceFacade, never()).patchResource(any(), any());
+ verify(customResourceFacade, never()).patchResource(any(), any(), any());
verify(reconciler, never()).cleanup(eq(testCustomResource), any());
}
@@ -471,7 +397,7 @@ void doesNotUpdatesObservedGenerationIfStatusIsNotPatchedWhenUsingSSA() throws E
CustomResourceFacade facade = mock(CustomResourceFacade.class);
when(config.isGenerationAware()).thenReturn(true);
when(reconciler.reconcile(any(), any())).thenReturn(UpdateControl.noUpdate());
- when(facade.patchStatus(any(), any())).thenReturn(observedGenResource);
+ when(facade.patchStatus(any(), any(), any())).thenReturn(observedGenResource);
var dispatcher = init(observedGenResource, reconciler, config, facade, true);
PostExecutionControl control =
@@ -489,12 +415,12 @@ void doesNotPatchObservedGenerationOnCustomResourcePatch() throws Exception {
when(config.isGenerationAware()).thenReturn(true);
when(reconciler.reconcile(any(), any()))
.thenReturn(UpdateControl.patchResource(observedGenResource));
- when(facade.patchResource(any(), any())).thenReturn(observedGenResource);
+ when(facade.patchResource(any(), any(), any())).thenReturn(observedGenResource);
var dispatcher = init(observedGenResource, reconciler, config, facade, false);
dispatcher.handleExecution(executionScopeWithCREvent(observedGenResource));
- verify(facade, never()).patchStatus(any(), any());
+ verify(facade, never()).patchStatus(any(), any(), any());
}
@Test
@@ -529,7 +455,7 @@ public boolean isLastAttempt() {
false)
.setResource(testCustomResource));
- verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any());
+ verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any());
verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any());
}
@@ -550,7 +476,7 @@ void callErrorStatusHandlerEvenOnFirstError() {
var postExecControl =
reconciliationDispatcher.handleExecution(
new ExecutionScope(null, null, false, false).setResource(testCustomResource));
- verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any());
+ verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any());
verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any());
assertThat(postExecControl.exceptionDuringExecution()).isTrue();
}
@@ -573,7 +499,7 @@ void errorHandlerCanInstructNoRetryWithUpdate() {
new ExecutionScope(null, null, false, false).setResource(testCustomResource));
verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any());
- verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any());
+ verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any());
assertThat(postExecControl.exceptionDuringExecution()).isFalse();
}
@@ -595,7 +521,7 @@ void errorHandlerCanInstructNoRetryNoUpdate() {
new ExecutionScope(null, null, false, false).setResource(testCustomResource));
verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any());
- verify(customResourceFacade, times(0)).patchStatus(eq(testCustomResource), any());
+ verify(customResourceFacade, times(0)).patchStatus(any(), eq(testCustomResource), any());
assertThat(postExecControl.exceptionDuringExecution()).isFalse();
}
@@ -611,7 +537,7 @@ void errorStatusHandlerCanPatchResource() {
reconciliationDispatcher.handleExecution(
new ExecutionScope(null, null, false, false).setResource(testCustomResource));
- verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any());
+ verify(customResourceFacade, times(1)).patchStatus(any(), eq(testCustomResource), any());
verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any());
}
@@ -659,30 +585,6 @@ void canSkipSchedulingMaxDelayIf() {
assertThat(control.getReScheduleDelay()).isNotPresent();
}
- @Test
- void retriesAddingFinalizerWithoutSSA() {
- initConfigService(false);
- reconciliationDispatcher =
- init(testCustomResource, reconciler, null, customResourceFacade, true);
-
- removeFinalizers(testCustomResource);
- reconciler.reconcile = (r, c) -> UpdateControl.noUpdate();
- when(customResourceFacade.patchResource(any(), any()))
- .thenThrow(new KubernetesClientException(null, 409, null))
- .thenReturn(testCustomResource);
- when(customResourceFacade.getResource(any(), any()))
- .then(
- (Answer)
- invocationOnMock -> {
- testCustomResource.getFinalizers().clear();
- return testCustomResource;
- });
-
- reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource));
-
- verify(customResourceFacade, times(2)).patchResource(any(), any());
- }
-
@Test
void reSchedulesFromErrorHandler() {
var delay = 1000L;
@@ -742,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());
@@ -751,12 +660,6 @@ private ObservedGenCustomResource createObservedGenCustomResource() {
return observedGenCustomResource;
}
- TestCustomResource createResourceWithFinalizer() {
- var resourceWithFinalizer = TestUtils.testCustomResource();
- resourceWithFinalizer.addFinalizer(DEFAULT_FINALIZER);
- return resourceWithFinalizer;
- }
-
private void removeFinalizers(CustomResource customResource) {
customResource.getMetadata().getFinalizers().clear();
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java
index 25e93a813c..d480dd06f8 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java
@@ -20,7 +20,7 @@
import org.junit.jupiter.api.Test;
import io.javaoperatorsdk.operator.TestUtils;
-import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/EventFilterTestUtils.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/EventFilterTestUtils.java
new file mode 100644
index 0000000000..72bcac0f54
--- /dev/null
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/EventFilterTestUtils.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.processing.event.source;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.UnaryOperator;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource;
+
+public class EventFilterTestUtils {
+
+ static ExecutorService executorService = Executors.newCachedThreadPool();
+
+ public static CountDownLatch sendForEventFilteringUpdate(
+ ManagedInformerEventSource eventSource, R resource, UnaryOperator updateMethod) {
+ try {
+ CountDownLatch latch = new CountDownLatch(1);
+ CountDownLatch sendOnGoingLatch = new CountDownLatch(1);
+ executorService.submit(
+ () ->
+ eventSource.eventFilteringUpdateAndCacheResource(
+ resource,
+ r -> {
+ try {
+ sendOnGoingLatch.countDown();
+ latch.await();
+ var resp = updateMethod.apply(r);
+ return resp;
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }));
+ sendOnGoingLatch.await();
+ return latch;
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static R withResourceVersion(R resource, int resourceVersion) {
+ var v = resource.getMetadata().getResourceVersion();
+ if (v == null) {
+ throw new IllegalArgumentException("Resource version is null");
+ }
+ resource.getMetadata().setResourceVersion("" + resourceVersion);
+ return resource;
+ }
+}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java
index dcd10b4225..df450b29a6 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java
@@ -17,12 +17,14 @@
import java.time.LocalDateTime;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import io.fabric8.kubernetes.client.KubernetesClientException;
import io.javaoperatorsdk.operator.MockKubernetesClient;
-import io.javaoperatorsdk.operator.ReconcilerUtils;
+import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.TestUtils;
import io.javaoperatorsdk.operator.api.config.BaseConfigurationService;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
@@ -34,11 +36,16 @@
import io.javaoperatorsdk.operator.processing.event.EventHandler;
import io.javaoperatorsdk.operator.processing.event.EventSourceManager;
import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase;
+import io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter;
import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter;
import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter;
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
+import static io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils.withResourceVersion;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@@ -46,7 +53,7 @@ class ControllerEventSourceTest
extends AbstractEventSourceTestBase, EventHandler> {
public static final String FINALIZER =
- ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class);
+ ReconcilerUtilsInternal.getDefaultFinalizerName(TestCustomResource.class);
private final TestController testController = new TestController(true);
private final ControllerConfiguration controllerConfig = mock(ControllerConfiguration.class);
@@ -68,10 +75,10 @@ void skipsEventHandlingIfGenerationNotIncreased() {
TestCustomResource oldCustomResource = TestUtils.testCustomResource();
oldCustomResource.getMetadata().setFinalizers(List.of(FINALIZER));
- source.eventReceived(ResourceAction.UPDATED, customResource, oldCustomResource, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null);
verify(eventHandler, times(1)).handleEvent(any());
- source.eventReceived(ResourceAction.UPDATED, customResource, customResource, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null);
verify(eventHandler, times(1)).handleEvent(any());
}
@@ -79,12 +86,12 @@ void skipsEventHandlingIfGenerationNotIncreased() {
void dontSkipEventHandlingIfMarkedForDeletion() {
TestCustomResource customResource1 = TestUtils.testCustomResource();
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(eventHandler, times(1)).handleEvent(any());
// mark for deletion
customResource1.getMetadata().setDeletionTimestamp(LocalDateTime.now().toString());
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(eventHandler, times(2)).handleEvent(any());
}
@@ -92,11 +99,11 @@ void dontSkipEventHandlingIfMarkedForDeletion() {
void normalExecutionIfGenerationChanges() {
TestCustomResource customResource1 = TestUtils.testCustomResource();
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(eventHandler, times(1)).handleEvent(any());
customResource1.getMetadata().setGeneration(2L);
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(eventHandler, times(2)).handleEvent(any());
}
@@ -107,10 +114,10 @@ void handlesAllEventIfNotGenerationAware() {
TestCustomResource customResource1 = TestUtils.testCustomResource();
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(eventHandler, times(1)).handleEvent(any());
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(eventHandler, times(2)).handleEvent(any());
}
@@ -118,7 +125,7 @@ void handlesAllEventIfNotGenerationAware() {
void eventWithNoGenerationProcessedIfNoFinalizer() {
TestCustomResource customResource1 = TestUtils.testCustomResource();
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(eventHandler, times(1)).handleEvent(any());
}
@@ -127,7 +134,7 @@ void eventWithNoGenerationProcessedIfNoFinalizer() {
void callsBroadcastsOnResourceEvents() {
TestCustomResource customResource1 = TestUtils.testCustomResource();
- source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null);
+ source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null);
verify(testController.getEventSourceManager(), times(1))
.broadcastOnResourceEvent(
@@ -143,8 +150,8 @@ void filtersOutEventsOnAddAndUpdate() {
source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null));
setUpSource(source, true, controllerConfig);
- source.eventReceived(ResourceAction.ADDED, cr, null, null);
- source.eventReceived(ResourceAction.UPDATED, cr, cr, null);
+ source.handleEvent(ResourceAction.ADDED, cr, null, null);
+ source.handleEvent(ResourceAction.UPDATED, cr, cr, null);
verify(eventHandler, never()).handleEvent(any());
}
@@ -156,13 +163,107 @@ void genericFilterFiltersOutAddUpdateAndDeleteEvents() {
source = new ControllerEventSource<>(new TestController(null, null, res -> false));
setUpSource(source, true, controllerConfig);
- source.eventReceived(ResourceAction.ADDED, cr, null, null);
- source.eventReceived(ResourceAction.UPDATED, cr, cr, null);
- source.eventReceived(ResourceAction.DELETED, cr, cr, true);
+ source.handleEvent(ResourceAction.ADDED, cr, null, null);
+ source.handleEvent(ResourceAction.UPDATED, cr, cr, null);
+ source.handleEvent(ResourceAction.DELETED, cr, cr, true);
verify(eventHandler, never()).handleEvent(any());
}
+ @Test
+ void testEventFilteringBasicScenario() throws InterruptedException {
+ source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
+ setUpSource(source, true, controllerConfig);
+
+ var latch = sendForEventFilteringUpdate(2);
+ source.onUpdate(testResourceWithVersion(1), testResourceWithVersion(2));
+ latch.countDown();
+
+ Thread.sleep(100);
+ verify(eventHandler, never()).handleEvent(any());
+ }
+
+ @Test
+ void eventFilteringNewEventDuringUpdate() {
+ source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
+ setUpSource(source, true, controllerConfig);
+
+ var latch = sendForEventFilteringUpdate(2);
+ source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3));
+ latch.countDown();
+
+ await().untilAsserted(() -> expectHandleEvent(3, 2));
+ }
+
+ @Test
+ void eventFilteringMoreNewEventsDuringUpdate() {
+ source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
+ setUpSource(source, true, controllerConfig);
+
+ var latch = sendForEventFilteringUpdate(2);
+ source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3));
+ source.onUpdate(testResourceWithVersion(3), testResourceWithVersion(4));
+ latch.countDown();
+
+ await().untilAsserted(() -> expectHandleEvent(4, 2));
+ }
+
+ @Test
+ void eventFilteringExceptionDuringUpdate() {
+ source = spy(new ControllerEventSource<>(new TestController(null, null, null)));
+ setUpSource(source, true, controllerConfig);
+
+ var latch =
+ EventFilterTestUtils.sendForEventFilteringUpdate(
+ source,
+ TestUtils.testCustomResource1(),
+ r -> {
+ throw new KubernetesClientException("fake");
+ });
+ source.onUpdate(testResourceWithVersion(1), testResourceWithVersion(2));
+ latch.countDown();
+
+ expectHandleEvent(2, 1);
+ }
+
+ private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) {
+ await()
+ .untilAsserted(
+ () -> {
+ verify(eventHandler, times(1)).handleEvent(any());
+ verify(source, times(1))
+ .handleEvent(
+ eq(ResourceAction.UPDATED),
+ argThat(
+ r -> {
+ assertThat(r.getMetadata().getResourceVersion())
+ .isEqualTo("" + newResourceVersion);
+ return true;
+ }),
+ argThat(
+ r -> {
+ assertThat(r.getMetadata().getResourceVersion())
+ .isEqualTo("" + oldResourceVersion);
+ return true;
+ }),
+ isNull());
+ });
+ }
+
+ private TestCustomResource testResourceWithVersion(int v) {
+ return withResourceVersion(TestUtils.testCustomResource1(), v);
+ }
+
+ private CountDownLatch sendForEventFilteringUpdate(int v) {
+ return sendForEventFilteringUpdate(TestUtils.testCustomResource1(), v);
+ }
+
+ private CountDownLatch sendForEventFilteringUpdate(
+ TestCustomResource testResource, int resourceVersion) {
+ return EventFilterTestUtils.sendForEventFilteringUpdate(
+ source, testResource, r -> withResourceVersion(testResource, resourceVersion));
+ }
+
@SuppressWarnings("unchecked")
private static class TestController extends Controller {
@@ -223,6 +324,7 @@ public TestConfiguration(
.withOnAddFilter(onAddFilter)
.withOnUpdateFilter(onUpdateFilter)
.withGenericFilter(genericFilter)
+ .withComparableResourceVersions(true)
.buildForController(),
false);
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java
index 208d6aeaaa..e2c3de8975 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java
@@ -15,8 +15,10 @@
*/
package io.javaoperatorsdk.operator.processing.event.source.informer;
+import java.time.Duration;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -25,6 +27,7 @@
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
import io.javaoperatorsdk.operator.MockKubernetesClient;
import io.javaoperatorsdk.operator.OperatorException;
import io.javaoperatorsdk.operator.api.config.BaseConfigurationService;
@@ -35,16 +38,25 @@
import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration;
import io.javaoperatorsdk.operator.processing.event.EventHandler;
import io.javaoperatorsdk.operator.processing.event.ResourceID;
+import io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils;
+import io.javaoperatorsdk.operator.processing.event.source.ResourceAction;
import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper;
+import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling;
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_NAMESPACES_SET;
+import static io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils.withResourceVersion;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -57,7 +69,7 @@ class InformerEventSourceTest {
private InformerEventSource informerEventSource;
private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class);
- private final TemporaryResourceCache temporaryResourceCacheMock =
+ private TemporaryResourceCache temporaryResourceCache =
mock(TemporaryResourceCache.class);
private final EventHandler eventHandlerMock = mock(EventHandler.class);
private final InformerEventSourceConfiguration