From 02dadab2b8772d3133c131e721f065d2dc81f9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 9 Oct 2025 15:04:55 +0200 Subject: [PATCH 01/58] improve: add license headers to source files (#2980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/event/source/CacheKeyMapper.java | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java 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..e69de29bb2 From 319355da9cc1be0670d9f1833f782153083571bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Oct 2025 15:54:26 +0200 Subject: [PATCH 02/58] chore: version to 5.3.0-SNAPSHOT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- bootstrapper-maven-plugin/pom.xml | 2 +- caffeine-bounded-cache-support/pom.xml | 2 +- micrometer-support/pom.xml | 2 +- operator-framework-bom/pom.xml | 2 +- operator-framework-core/pom.xml | 2 +- operator-framework-junit5/pom.xml | 2 +- operator-framework/pom.xml | 2 +- pom.xml | 2 +- sample-operators/controller-namespace-deletion/pom.xml | 2 +- sample-operators/leader-election/pom.xml | 2 +- sample-operators/mysql-schema/pom.xml | 2 +- sample-operators/pom.xml | 2 +- sample-operators/tomcat-operator/pom.xml | 2 +- sample-operators/webpage/pom.xml | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml index cd5d69bb8b..48140ccd0b 100644 --- a/bootstrapper-maven-plugin/pom.xml +++ b/bootstrapper-maven-plugin/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT bootstrapper diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index c1cfea99e1..a050e0ff3c 100644 --- a/caffeine-bounded-cache-support/pom.xml +++ b/caffeine-bounded-cache-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT caffeine-bounded-cache-support diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index 0dc734be3b..93765e4a8a 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT micrometer-support diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 04d0f9273d..3546390457 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk operator-framework-bom - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT pom Operator SDK - Bill of Materials Java SDK for implementing Kubernetes operators diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index aa95a5078b..72fe2e8188 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT ../pom.xml diff --git a/operator-framework-junit5/pom.xml b/operator-framework-junit5/pom.xml index 9696bea8fc..60c235a9ec 100644 --- a/operator-framework-junit5/pom.xml +++ b/operator-framework-junit5/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT operator-framework-junit-5 diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index bef52336b0..253907eb1e 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT operator-framework diff --git a/pom.xml b/pom.xml index 393ea4a311..e63498dd94 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT pom Operator SDK for Java Java SDK for implementing Kubernetes operators diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index 6af22e1ddf..ddfeebacbc 100644 --- a/sample-operators/controller-namespace-deletion/pom.xml +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT sample-controller-namespace-deletion diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index db71c9440c..70485a2f3e 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT sample-leader-election diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index 2fe292759b..a2334ca8c6 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT sample-mysql-schema-operator diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 46d70eaf7b..6079d3bb71 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT sample-operators diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 14a4d96c29..c9fe8c2d06 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT sample-tomcat-operator diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 357863110c..e25920b7da 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT sample-webpage-operator From eb4b21be1230a2e51bdfaf04f698b02d201fa48d Mon Sep 17 00:00:00 2001 From: Steven Hawkins Date: Thu, 30 Oct 2025 08:22:09 -0400 Subject: [PATCH 03/58] Annotation removal using locking (#3015) Signed-off-by: Steve Hawkins --- .../api/config/ConfigurationService.java | 63 +---- .../config/ConfigurationServiceOverrider.java | 45 +--- .../InformerEventSourceConfiguration.java | 22 +- .../operator/api/reconciler/Constants.java | 1 + .../PrimaryUpdateAndCacheUtils.java | 41 +++ .../KubernetesDependentResource.java | 46 ++-- .../controller/ControllerEventSource.java | 6 +- .../source/informer/InformerEventSource.java | 133 +++------- .../source/informer/InformerWrapper.java | 4 + .../informer/ManagedInformerEventSource.java | 39 +-- .../informer/TemporaryResourceCache.java | 237 +++++++++--------- .../informer/InformerEventSourceTest.java | 19 +- .../TemporaryPrimaryResourceCacheTest.java | 139 +++++----- ...ComparableResourceVersionsDisabledIT.java} | 0 .../ExternalStateReconciler.java | 7 +- 15 files changed, 361 insertions(+), 441 deletions(-) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/{PreviousAnnotationDisabledIT.java => ComparableResourceVersionsDisabledIT.java} (100%) 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..99bb280ae4 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; @@ -46,6 +44,8 @@ import io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowFactory; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSIONS; + /** An interface from which to retrieve configuration information. */ public interface ConfigurationService { @@ -448,61 +448,16 @@ default Set> defaultNonSSAResource() { } /** - * 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. + * If the event logic can compare resourceVersions. * - *

Disable this if you want to react to your own dependent resource updates + *

Enabled by default as Kubernetes does support this interpretation of resourceVersions. + * Disable only if your api server provides non comparable resource versions. * - * @return if special annotation should be used for dependent resource to filter events - * @since 4.5.0 + * @return if resource versions are comparable + * @since 5.3.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; + default boolean comparableResourceVersions() { + return DEFAULT_COMPARABLE_RESOURCE_VERSIONS; } /** 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..81a5428044 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,9 @@ public class ConfigurationServiceOverrider { private Duration reconciliationTerminationTimeout; private Boolean ssaBasedCreateUpdateMatchForDependentResources; private Set> defaultNonSSAResource; - private Boolean previousAnnotationForDependentResources; - private Boolean parseResourceVersions; + private Boolean comparableResourceVersions; private Boolean useSSAToPatchPrimaryResource; private Boolean cloneSecondaryResourcesWhenGettingFromCache; - private Set> previousAnnotationUsageBlocklist; @SuppressWarnings("rawtypes") private DependentResourceFactory dependentResourceFactory; @@ -168,28 +166,23 @@ 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; + public ConfigurationServiceOverrider withComparableResourceVersions(boolean value) { + this.comparableResourceVersions = value; return this; } /** - * @deprecated use withParseResourceVersions + * @deprecated use withComparableResourceVersions * @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; + public ConfigurationServiceOverrider withParseResourceVersions(boolean value) { + this.comparableResourceVersions = value; return this; } @@ -204,12 +197,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 +318,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( @@ -359,11 +332,9 @@ public boolean cloneSecondaryResourcesWhenGettingFromCache() { } @Override - public Set> - withPreviousAnnotationForDependentResourcesBlocklist() { + public boolean comparableResourceVersions() { return overriddenValueOrDefault( - previousAnnotationUsageBlocklist, - ConfigurationService::withPreviousAnnotationForDependentResourcesBlocklist); + comparableResourceVersions, ConfigurationService::comparableResourceVersions); } }; } 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..c6ea21f0c0 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_VERSIONS; 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 comparableResourceVersions; protected DefaultInformerEventSourceConfiguration( GroupVersionKind groupVersionKind, PrimaryToSecondaryMapper primaryToSecondaryMapper, SecondaryToPrimaryMapper secondaryToPrimaryMapper, InformerConfiguration informerConfig, - KubernetesClient kubernetesClient) { + KubernetesClient kubernetesClient, + boolean comparableResourceVersions) { this.informerConfig = Objects.requireNonNull(informerConfig); this.groupVersionKind = groupVersionKind; this.primaryToSecondaryMapper = primaryToSecondaryMapper; this.secondaryToPrimaryMapper = secondaryToPrimaryMapper; this.kubernetesClient = kubernetesClient; + this.comparableResourceVersions = comparableResourceVersions; } @Override @@ -135,6 +139,11 @@ public Optional getGroupVersionKind() { public Optional getKubernetesClient() { return Optional.ofNullable(kubernetesClient); } + + @Override + public boolean comparableResourceVersions() { + return this.comparableResourceVersions; + } } @SuppressWarnings({"unused", "UnusedReturnValue"}) @@ -148,6 +157,7 @@ class Builder { private PrimaryToSecondaryMapper primaryToSecondaryMapper; private SecondaryToPrimaryMapper secondaryToPrimaryMapper; private KubernetesClient kubernetesClient; + private boolean comparableResourceVersions = DEFAULT_COMPARABLE_RESOURCE_VERSIONS; private Builder(Class resourceClass, Class primaryResourceClass) { this(resourceClass, primaryResourceClass, null); @@ -285,6 +295,11 @@ public Builder withFieldSelector(FieldSelector fieldSelector) { return this; } + public Builder withComparableResourceVersions(boolean comparableResourceVersions) { + this.comparableResourceVersions = comparableResourceVersions; + 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, + comparableResourceVersions); } } + + boolean comparableResourceVersions(); } 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..ed975d71ef 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_VERSIONS = true; private Constants() {} } 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..11dfd21648 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 @@ -450,4 +450,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/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java index 05cddcade1..562a6257b5 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 @@ -55,7 +55,6 @@ public abstract class KubernetesDependentResource kubernetesDependentResourceConfig; private volatile Boolean useSSA; - private volatile Boolean usePreviousAnnotationForEventFiltering; public KubernetesDependentResource() {} @@ -72,6 +71,27 @@ public void configureWith(KubernetesDependentResourceConfig config) { this.kubernetesDependentResourceConfig = config; } + @Override + protected R handleCreate(R desired, P primary, Context

context) { + return eventSource() + .orElseThrow() + .updateAndCacheResource( + desired, + context, + toCreate -> KubernetesDependentResource.super.handleCreate(toCreate, primary, context)); + } + + @Override + protected R handleUpdate(R actual, R desired, P primary, Context

context) { + return eventSource() + .orElseThrow() + .updateAndCacheResource( + desired, + context, + toUpdate -> + KubernetesDependentResource.super.handleUpdate(actual, toUpdate, primary, context)); + } + @SuppressWarnings("unused") public R create(R desired, P primary, Context

context) { if (useSSA(context)) { @@ -158,14 +178,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 +193,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) { 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..59d86efe48 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 @@ -47,7 +47,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().getConfigurationService().comparableResourceVersions()); this.controller = controller; final var config = controller.getConfiguration(); 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..d46dd0669a 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,7 @@ import java.util.Optional; import java.util.Set; -import java.util.UUID; +import java.util.function.UnaryOperator; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -35,6 +35,8 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSIONS; + /** * 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 @@ -78,28 +80,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.comparableResourceVersions()); } InformerEventSource(InformerEventSourceConfiguration configuration, KubernetesClient client) { - this(configuration, client, false); + this(configuration, client, DEFAULT_COMPARABLE_RESOURCE_VERSIONS); } @SuppressWarnings({"unchecked", "rawtypes"}) private InformerEventSource( InformerEventSourceConfiguration configuration, KubernetesClient client, - boolean parseResourceVersions) { + boolean comparableResourceVersions) { super( configuration.name(), configuration @@ -107,7 +105,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()) { @@ -125,6 +123,22 @@ private InformerEventSource( genericFilter = informerConfig.getGenericFilter(); } + public R updateAndCacheResource( + R resourceToUpdate, Context context, UnaryOperator updateMethod) { + ResourceID id = ResourceID.fromResource(resourceToUpdate); + if (log.isDebugEnabled()) { + log.debug("Update and cache: {}", id); + } + try { + temporaryResourceCache.startModifying(id); + var updated = updateMethod.apply(resourceToUpdate); + handleRecentResourceUpdate(id, updated, resourceToUpdate); + return updated; + } finally { + temporaryResourceCache.doneModifying(id); + } + } + @Override public void onAdd(R newResource) { if (log.isDebugEnabled()) { @@ -134,9 +148,7 @@ public void onAdd(R newResource) { resourceType().getSimpleName(), newResource.getMetadata().getResourceVersion()); } - primaryToSecondaryIndex.onAddOrUpdate(newResource); - onAddOrUpdate( - Operation.ADD, newResource, null, () -> InformerEventSource.super.onAdd(newResource)); + onAddOrUpdate(Operation.ADD, newResource, null); } @Override @@ -149,16 +161,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(Operation.UPDATE, 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: {}", @@ -180,68 +187,28 @@ 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(Operation operation, R newObject, R oldObject) { + primaryToSecondaryIndex.onAddOrUpdate(newObject); var resourceID = ResourceID.fromResource(newObject); - if (canSkipEvent(newObject, oldObject, resourceID)) { + if (temporaryResourceCache.onAddOrUpdateEvent(newObject)) { log.debug( "Skipping event propagation for {}, since was a result of a reconcile action. Resource" + " ID: {}", operation, ResourceID.fromResource(newObject)); - superOnOp.run(); + } else 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 { - 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: {}", operation, 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); @@ -289,23 +256,19 @@ public Set getSecondaryResources(P primary) { } @Override - public synchronized void handleRecentResourceUpdate( + public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { handleRecentCreateOrUpdate(Operation.UPDATE, resource, previousVersionOfResource); } @Override - public synchronized void handleRecentResourceCreate(ResourceID resourceID, R resource) { + public void handleRecentResourceCreate(ResourceID resourceID, R resource) { handleRecentCreateOrUpdate(Operation.ADD, resource, null); } private void handleRecentCreateOrUpdate(Operation operation, R newResource, R oldResource) { primaryToSecondaryIndex.onAddOrUpdate(newResource); - temporaryResourceCache.putResource( - newResource, - Optional.ofNullable(oldResource) - .map(r -> r.getMetadata().getResourceVersion()) - .orElse(null)); + temporaryResourceCache.putResource(newResource); } private boolean useSecondaryToPrimaryIndex() { @@ -333,22 +296,6 @@ private boolean acceptedByDeleteFilters(R resource, boolean 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/InformerWrapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java index 2a6c7ef206..c3a4a9f2c1 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 @@ -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..af30617d92 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 @@ -34,6 +34,7 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Informable; import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; +import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; @@ -55,7 +56,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,9 +64,9 @@ 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; } @@ -102,7 +103,7 @@ 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 +123,34 @@ 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 -> + PrimaryUpdateAndCacheUtils.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..d918be447d 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,16 +15,16 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; -import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -33,157 +33,152 @@ * 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; - } - }; - } + private static final Logger log = LoggerFactory.getLogger(TemporaryResourceCache.class); - public void add(K key) { - clean(); - cache.putIfAbsent(key, System.currentTimeMillis()); - } + private final Map cache = new ConcurrentHashMap<>(); + private final boolean comparableResourceVersions; + private final Map activelyModifying = new ConcurrentHashMap<>(); + private String latestResourceVersion; - public boolean contains(K key) { - clean(); - return cache.get(key) != null; - } + public TemporaryResourceCache(boolean comparableResourceVersions) { + this.comparableResourceVersions = comparableResourceVersions; + } - 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(); - } - } - } + public void startModifying(ResourceID id) { + if (!comparableResourceVersions) { + return; } + activelyModifying + .compute( + id, + (ignored, lock) -> { + if (lock != null) { + throw new IllegalStateException(); // concurrent modifications to the same resource + // not allowed - this could be relaxed if needed + } + return new ReentrantLock(); + }) + .lock(); } - private static final Logger log = LoggerFactory.getLogger(TemporaryResourceCache.class); - - private final Map cache = new ConcurrentHashMap<>(); - - // 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; - - public TemporaryResourceCache( - ManagedInformerEventSource managedInformerEventSource, - boolean parseResourceVersions) { - this.managedInformerEventSource = managedInformerEventSource; - this.parseResourceVersions = parseResourceVersions; + public void doneModifying(ResourceID id) { + if (!comparableResourceVersions) { + return; + } + activelyModifying.computeIfPresent( + id, + (ignored, lock) -> { + lock.unlock(); + return null; + }); } - public synchronized void onDeleteEvent(T resource, boolean unknownState) { - tombstones.add(resource.getMetadata().getUid()); + public void onDeleteEvent(T resource, boolean unknownState) { onEvent(resource, unknownState); } - public synchronized void onAddOrUpdateEvent(T resource) { - onEvent(resource, false); + /** + * @return true if the resourceVersion was already known + */ + public boolean onAddOrUpdateEvent(T resource) { + return onEvent(resource, false); } - synchronized void onEvent(T resource, boolean unknownState) { - cache.computeIfPresent( - ResourceID.fromResource(resource), - (id, cached) -> - (unknownState || !isLaterResourceVersion(id, cached, resource)) ? null : cached); + private boolean onEvent(T resource, boolean unknownState) { + ReentrantLock lock = activelyModifying.get(ResourceID.fromResource(resource)); + if (lock != null) { + lock.lock(); // wait for the modification to finish + lock.unlock(); // simply unlock as the event is guaranteed after the modification + } + boolean[] known = new boolean[1]; + synchronized (this) { + if (!unknownState) { + latestResourceVersion = resource.getMetadata().getResourceVersion(); + } + cache.computeIfPresent( + ResourceID.fromResource(resource), + (id, cached) -> { + boolean remove = unknownState; + if (!unknownState) { + int comp = PrimaryUpdateAndCacheUtils.compareResourceVersions(resource, cached); + if (comp >= 0) { + remove = true; + } + if (comp <= 0) { + known[0] = true; + } + } + if (remove) { + return null; + } + return cached; + }); + return known[0]; + } } - public synchronized void putAddedResource(T newResource) { - putResource(newResource, null); - } + /** 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; + } - /** - * 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 - */ - 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; - } - // we can skip further checks as this is a simple add and there's no previous entry to - // consider - moveAhead = true; + + 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 + && PrimaryUpdateAndCacheUtils.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 + || PrimaryUpdateAndCacheUtils.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/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..f54e47304b 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -94,31 +94,18 @@ public synchronized void start() {} } @Test - void skipsEventPropagationIfResourceWithSameVersionInResourceCache() { + void skipsEventPropagation() { when(temporaryResourceCacheMock.getResourceFromCache(any())) .thenReturn(Optional.of(testDeployment())); + when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(true); + informerEventSource.onAdd(testDeployment()); informerEventSource.onUpdate(testDeployment(), testDeployment()); verify(eventHandlerMock, never()).handleEvent(any()); } - @Test - void skipsAddEventPropagationViaAnnotation() { - informerEventSource.onAdd(informerEventSource.addPreviousAnnotation(null, testDeployment())); - - verify(eventHandlerMock, never()).handleEvent(any()); - } - - @Test - void skipsUpdateEventPropagationViaAnnotation() { - informerEventSource.onUpdate( - testDeployment(), informerEventSource.addPreviousAnnotation("1", testDeployment())); - - verify(eventHandlerMock, never()).handleEvent(any()); - } - @Test void processEventPropagationWithoutAnnotation() { informerEventSource.onUpdate(testDeployment(), testDeployment()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java index e3dc2c82e4..4b12148015 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java @@ -16,10 +16,10 @@ package io.javaoperatorsdk.operator.processing.event.source.informer; import java.util.Map; -import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import org.awaitility.Awaitility; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,49 +27,40 @@ import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.ExpirationCache; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; class TemporaryPrimaryResourceCacheTest { public static final String RESOURCE_VERSION = "2"; - @SuppressWarnings("unchecked") - private InformerEventSource informerEventSource; - private TemporaryResourceCache temporaryResourceCache; @BeforeEach void setup() { - informerEventSource = mock(InformerEventSource.class); - temporaryResourceCache = new TemporaryResourceCache<>(informerEventSource, false); + temporaryResourceCache = new TemporaryResourceCache<>(true); } @Test void updateAddsTheResourceIntoCacheIfTheInformerHasThePreviousResourceVersion() { var testResource = testResource(); var prevTestResource = testResource(); - prevTestResource.getMetadata().setResourceVersion("0"); - when(informerEventSource.get(any())).thenReturn(Optional.of(prevTestResource)); + prevTestResource.getMetadata().setResourceVersion("1"); - temporaryResourceCache.putResource(testResource, "0"); + temporaryResourceCache.putResource(testResource); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); assertThat(cached).isPresent(); } @Test - void updateNotAddsTheResourceIntoCacheIfTheInformerHasOtherVersion() { + void updateNotAddsTheResourceIntoCacheIfLaterVersionKnown() { var testResource = testResource(); - var informerCachedResource = testResource(); - informerCachedResource.getMetadata().setResourceVersion("x"); - when(informerEventSource.get(any())).thenReturn(Optional.of(informerCachedResource)); - temporaryResourceCache.putResource(testResource, "0"); + temporaryResourceCache.onAddOrUpdateEvent( + testResource.toBuilder().editMetadata().withResourceVersion("3").endMetadata().build()); + + temporaryResourceCache.putResource(testResource); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); assertThat(cached).isNotPresent(); @@ -78,9 +69,8 @@ void updateNotAddsTheResourceIntoCacheIfTheInformerHasOtherVersion() { @Test void addOperationAddsTheResourceIfInformerCacheStillEmpty() { var testResource = testResource(); - when(informerEventSource.get(any())).thenReturn(Optional.empty()); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.putResource(testResource); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); assertThat(cached).isPresent(); @@ -89,46 +79,79 @@ void addOperationAddsTheResourceIfInformerCacheStillEmpty() { @Test void addOperationNotAddsTheResourceIfInformerCacheNotEmpty() { var testResource = testResource(); - when(informerEventSource.get(any())).thenReturn(Optional.of(testResource())); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.putResource(testResource); + + temporaryResourceCache.putResource( + new ConfigMapBuilder(testResource) + .editMetadata() + .withResourceVersion("1") + .endMetadata() + .build()); var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); - assertThat(cached).isNotPresent(); + assertThat(cached.orElseThrow().getMetadata().getResourceVersion()).isEqualTo(RESOURCE_VERSION); } @Test void removesResourceFromCache() { ConfigMap testResource = propagateTestResourceToCache(); - temporaryResourceCache.onAddOrUpdateEvent(testResource()); + temporaryResourceCache.onAddOrUpdateEvent( + new ConfigMapBuilder(testResource) + .editMetadata() + .withResourceVersion("3") + .endMetadata() + .build()); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isNotPresent(); } @Test - void resourceVersionParsing() { - this.temporaryResourceCache = new TemporaryResourceCache<>(informerEventSource, true); + void nonComparableResourceVersionsDisables() { + this.temporaryResourceCache = new TemporaryResourceCache<>(false); - ConfigMap testResource = propagateTestResourceToCache(); + this.temporaryResourceCache.putResource(testResource()); - // an event with a newer version will not remove - temporaryResourceCache.onAddOrUpdateEvent( - new ConfigMapBuilder(testResource) - .editMetadata() - .withResourceVersion("1") - .endMetadata() - .build()); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource()))) + .isEmpty(); + } - assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) - .isPresent(); + @Test + void lockedEventBeforePut() throws Exception { + var testResource = testResource(); - // anything else will remove - temporaryResourceCache.onAddOrUpdateEvent(testResource()); + temporaryResourceCache.startModifying(ResourceID.fromResource(testResource)); - assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) - .isNotPresent(); + ExecutorService ex = Executors.newSingleThreadExecutor(); + try { + var result = ex.submit(() -> temporaryResourceCache.onAddOrUpdateEvent(testResource)); + + temporaryResourceCache.putResource(testResource); + assertThat(result.isDone()).isFalse(); + temporaryResourceCache.doneModifying(ResourceID.fromResource(testResource)); + assertThat(result.get(10, TimeUnit.SECONDS)).isTrue(); + } finally { + ex.shutdownNow(); + } + } + + @Test + void putBeforeEvent() { + var testResource = testResource(); + + // first ensure an event is not known + var result = temporaryResourceCache.onAddOrUpdateEvent(testResource); + assertThat(result).isFalse(); + + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("3"); + temporaryResourceCache.putResource(nextResource); + + // now expect an event with the matching resourceVersion to be known after the put + result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + assertThat(result).isTrue(); } @Test @@ -143,45 +166,15 @@ void rapidDeletion() { .endMetadata() .build(), false); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.putResource(testResource); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isEmpty(); } - @Test - void expirationCacheMax() { - ExpirationCache cache = new ExpirationCache<>(2, Integer.MAX_VALUE); - - cache.add(1); - cache.add(2); - cache.add(3); - - assertThat(cache.contains(1)).isFalse(); - assertThat(cache.contains(2)).isTrue(); - assertThat(cache.contains(3)).isTrue(); - } - - @Test - void expirationCacheTtl() { - ExpirationCache cache = new ExpirationCache<>(2, 1); - - cache.add(1); - cache.add(2); - - Awaitility.await() - .atMost(1, TimeUnit.SECONDS) - .untilAsserted( - () -> { - assertThat(cache.contains(1)).isFalse(); - assertThat(cache.contains(2)).isFalse(); - }); - } - private ConfigMap propagateTestResourceToCache() { var testResource = testResource(); - when(informerEventSource.get(any())).thenReturn(Optional.empty()); - temporaryResourceCache.putAddedResource(testResource); + temporaryResourceCache.putResource(testResource); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isPresent(); return testResource; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.java similarity index 100% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java index de485cfc4e..89d1dee94b 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java @@ -104,13 +104,15 @@ private void createExternalResource( .withData(Map.of(ID_KEY, createdResource.getId())) .build(); configMap.addOwnerReference(resource); - context.getClient().configMaps().resource(configMap).create(); var primaryID = ResourceID.fromResource(resource); // Making sure that the created resources are in the cache for the next reconciliation. // This is critical in this case, since on next reconciliation if it would not be in the cache // it would be created again. - configMapEventSource.handleRecentResourceCreate(primaryID, configMap); + configMapEventSource.updateAndCacheResource( + configMap, + context, + toCreate -> context.getClient().configMaps().resource(toCreate).create()); externalResourceEventSource.handleRecentResourceCreate(primaryID, createdResource); } @@ -128,6 +130,7 @@ public DeleteControl cleanup( return DeleteControl.defaultDelete(); } + @Override public int getNumberOfExecutions() { return numberOfExecutions.get(); } From f1550740fd2365712f83557dba7917db5e6f9715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 13 Nov 2025 04:43:22 +0100 Subject: [PATCH 04/58] improve: complete comparable resource version configs (#3027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/ConfigurationService.java | 15 ----------- .../config/ConfigurationServiceOverrider.java | 27 ------------------- .../api/config/informer/Informer.java | 8 ++++++ .../informer/InformerConfiguration.java | 18 +++++++++++-- .../InformerEventSourceConfiguration.java | 22 +++++++-------- .../operator/api/reconciler/Constants.java | 2 +- .../controller/ControllerEventSource.java | 2 +- .../source/informer/InformerEventSource.java | 6 ++--- .../ComparableResourceVersionsDisabledIT.java | 4 +-- ...CreateUpdateEventFilterTestReconciler.java | 11 ++++++++ 10 files changed, 52 insertions(+), 63 deletions(-) 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 99bb280ae4..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 @@ -44,8 +44,6 @@ import io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowFactory; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; -import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSIONS; - /** An interface from which to retrieve configuration information. */ public interface ConfigurationService { @@ -447,19 +445,6 @@ default Set> defaultNonSSAResource() { return defaultNonSSAResources(); } - /** - * If the event logic can compare resourceVersions. - * - *

Enabled by default as Kubernetes does support this interpretation of resourceVersions. - * Disable only if your api server provides non comparable resource versions. - * - * @return if resource versions are comparable - * @since 5.3.0 - */ - default boolean comparableResourceVersions() { - return DEFAULT_COMPARABLE_RESOURCE_VERSIONS; - } - /** * {@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 81a5428044..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,7 +51,6 @@ public class ConfigurationServiceOverrider { private Duration reconciliationTerminationTimeout; private Boolean ssaBasedCreateUpdateMatchForDependentResources; private Set> defaultNonSSAResource; - private Boolean comparableResourceVersions; private Boolean useSSAToPatchPrimaryResource; private Boolean cloneSecondaryResourcesWhenGettingFromCache; @@ -166,26 +165,6 @@ public ConfigurationServiceOverrider withDefaultNonSSAResource( return this; } - /** - * @param value true if internal algorithms can use metadata.resourceVersion as a numeric value. - * @return this - */ - public ConfigurationServiceOverrider withComparableResourceVersions(boolean value) { - this.comparableResourceVersions = value; - return this; - } - - /** - * @deprecated use withComparableResourceVersions - * @param value true if internal algorithms can use metadata.resourceVersion as a numeric value. - * @return this - */ - @Deprecated(forRemoval = true) - public ConfigurationServiceOverrider withParseResourceVersions(boolean value) { - this.comparableResourceVersions = value; - return this; - } - public ConfigurationServiceOverrider withUseSSAToPatchPrimaryResource(boolean value) { this.useSSAToPatchPrimaryResource = value; return this; @@ -330,12 +309,6 @@ public boolean cloneSecondaryResourcesWhenGettingFromCache() { cloneSecondaryResourcesWhenGettingFromCache, ConfigurationService::cloneSecondaryResourcesWhenGettingFromCache); } - - @Override - public boolean comparableResourceVersions() { - return overriddenValueOrDefault( - comparableResourceVersions, ConfigurationService::comparableResourceVersions); - } }; } } 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..30a1a32e8a 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 @@ -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 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) { @@ -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 c6ea21f0c0..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,7 +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_VERSIONS; +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; @@ -97,7 +97,7 @@ class DefaultInformerEventSourceConfiguration private final GroupVersionKind groupVersionKind; private final InformerConfiguration informerConfig; private final KubernetesClient kubernetesClient; - private final boolean comparableResourceVersions; + private final boolean comparableResourceVersion; protected DefaultInformerEventSourceConfiguration( GroupVersionKind groupVersionKind, @@ -105,13 +105,13 @@ protected DefaultInformerEventSourceConfiguration( SecondaryToPrimaryMapper secondaryToPrimaryMapper, InformerConfiguration informerConfig, KubernetesClient kubernetesClient, - boolean comparableResourceVersions) { + boolean comparableResourceVersion) { this.informerConfig = Objects.requireNonNull(informerConfig); this.groupVersionKind = groupVersionKind; this.primaryToSecondaryMapper = primaryToSecondaryMapper; this.secondaryToPrimaryMapper = secondaryToPrimaryMapper; this.kubernetesClient = kubernetesClient; - this.comparableResourceVersions = comparableResourceVersions; + this.comparableResourceVersion = comparableResourceVersion; } @Override @@ -141,8 +141,8 @@ public Optional getKubernetesClient() { } @Override - public boolean comparableResourceVersions() { - return this.comparableResourceVersions; + public boolean comparableResourceVersion() { + return this.comparableResourceVersion; } } @@ -157,7 +157,7 @@ class Builder { private PrimaryToSecondaryMapper primaryToSecondaryMapper; private SecondaryToPrimaryMapper secondaryToPrimaryMapper; private KubernetesClient kubernetesClient; - private boolean comparableResourceVersions = DEFAULT_COMPARABLE_RESOURCE_VERSIONS; + private boolean comparableResourceVersion = DEFAULT_COMPARABLE_RESOURCE_VERSION; private Builder(Class resourceClass, Class primaryResourceClass) { this(resourceClass, primaryResourceClass, null); @@ -295,8 +295,8 @@ public Builder withFieldSelector(FieldSelector fieldSelector) { return this; } - public Builder withComparableResourceVersions(boolean comparableResourceVersions) { - this.comparableResourceVersions = comparableResourceVersions; + public Builder withComparableResourceVersion(boolean comparableResourceVersion) { + this.comparableResourceVersion = comparableResourceVersion; return this; } @@ -340,9 +340,9 @@ public InformerEventSourceConfiguration build() { false)), config.build(), kubernetesClient, - comparableResourceVersions); + comparableResourceVersion); } } - boolean comparableResourceVersions(); + boolean comparableResourceVersion(); } 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 ed975d71ef..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,7 +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_VERSIONS = true; + public static final boolean DEFAULT_COMPARABLE_RESOURCE_VERSION = true; private Constants() {} } 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 59d86efe48..f7ed9fdc8e 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 @@ -51,7 +51,7 @@ public ControllerEventSource(Controller controller) { NAME, controller.getCRClient(), controller.getConfiguration(), - controller.getConfiguration().getConfigurationService().comparableResourceVersions()); + controller.getConfiguration().getInformerConfig().isComparableResourceVersions()); this.controller = controller; final var config = controller.getConfiguration(); 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 d46dd0669a..c6a0c782e3 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 @@ -35,7 +35,7 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; -import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSIONS; +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 @@ -86,11 +86,11 @@ public InformerEventSource( this( configuration, configuration.getKubernetesClient().orElse(context.getClient()), - configuration.comparableResourceVersions()); + configuration.comparableResourceVersion()); } InformerEventSource(InformerEventSourceConfiguration configuration, KubernetesClient client) { - this(configuration, client, DEFAULT_COMPARABLE_RESOURCE_VERSIONS); + this(configuration, client, DEFAULT_COMPARABLE_RESOURCE_VERSION); } @SuppressWarnings({"unchecked", "rawtypes"}) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.java index 17fe6b7125..6577d4ca59 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/ComparableResourceVersionsDisabledIT.java @@ -34,9 +34,7 @@ class PreviousAnnotationDisabledIT { @RegisterExtension LocallyRunOperatorExtension operator = LocallyRunOperatorExtension.builder() - .withReconciler(new CreateUpdateEventFilterTestReconciler()) - .withConfigurationService( - overrider -> overrider.withPreviousAnnotationForDependentResources(false)) + .withReconciler(new CreateUpdateEventFilterTestReconciler(false)) .build(); @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java index 40bf2cc350..4344356ff9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java @@ -41,6 +41,16 @@ public class CreateUpdateEventFilterTestReconciler private final DirectConfigMapDependentResource configMapDR = new DirectConfigMapDependentResource(ConfigMap.class); + private final boolean comparableResourceVersion; + + public CreateUpdateEventFilterTestReconciler(boolean comparableResourceVersion) { + this.comparableResourceVersion = comparableResourceVersion; + } + + public CreateUpdateEventFilterTestReconciler() { + this(true); + } + @Override public UpdateControl reconcile( CreateUpdateEventFilterTestCustomResource resource, @@ -89,6 +99,7 @@ public List> prepareEv InformerEventSourceConfiguration.from( ConfigMap.class, CreateUpdateEventFilterTestCustomResource.class) .withLabelSelector("integrationtest = " + this.getClass().getSimpleName()) + .withComparableResourceVersion(comparableResourceVersion) .build(); final var informerEventSource = new InformerEventSource<>(informerConfiguration, context); From 678eafed6ad4809c09a675dea3fdab8fcaf802b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 17 Nov 2025 17:39:11 +0100 Subject: [PATCH 05/58] improve: run pr-s checks for v5.3 (#3042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .github/workflows/e2e-test.yml | 3 +- .../PrimaryUpdateAndCacheUtilsTest.java | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e146009885..86f527e727 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -6,7 +6,7 @@ on: paths-ignore: - 'docs/**' - 'adr/**' - branches: [ main, next ] + branches: [ main, next, v5.3 ] push: paths-ignore: - 'docs/**' @@ -14,6 +14,7 @@ on: branches: - main - next + - v5.3 jobs: sample_operators_tests: 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..1a009eb57c 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 @@ -180,4 +180,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); + } } From eb205acb97863ebde33e37f62a1e2ffca47b3750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 1 Dec 2025 11:17:06 +0100 Subject: [PATCH 06/58] fix: rebase on main after release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/source/CacheKeyMapper.java | 15 +++++++++++++++ .../PrimaryUpdateAndCacheUtilsTest.java | 2 ++ test-index-processor/pom.xml | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) 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 index e69de29bb2..3e1a4f9b14 100644 --- 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 @@ -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/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java index 1a009eb57c..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; diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml index acfddc276a..5ea4008f78 100644 --- a/test-index-processor/pom.xml +++ b/test-index-processor/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.4-SNAPSHOT + 5.3.0-SNAPSHOT test-index-processor From bf1b996ea78dccb0fdb86a3b06e97b29e5efefde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 1 Dec 2025 15:53:03 +0100 Subject: [PATCH 07/58] fix(javadoc): invalid method ref blocks snapshot release (#3076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simplified the javadoc Signed-off-by: Attila Mészáros --- .../source/informer/InformerEventSource.java | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) 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 c6a0c782e3..0feb3dc2a8 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 @@ -40,33 +40,8 @@ /** * 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: - * - *

    - *
  1. 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. - *
  2. 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 From ecd9988eb9bb8d9246585bc53488d3b04c458e07 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Wed, 3 Dec 2025 09:47:12 +0100 Subject: [PATCH 08/58] feat: record desired state in Context (#3082) Signed-off-by: Chris Laprun --- .../api/reconciler/DefaultContext.java | 13 +++ .../dependent/AbstractDependentResource.java | 28 +++++- .../AbstractExternalDependentResource.java | 2 +- .../BulkDependentResourceReconciler.java | 1 - .../GenericKubernetesResourceMatcher.java | 4 +- .../KubernetesDependentResource.java | 8 +- .../AbstractDependentResourceTest.java | 87 ++++++++++++++----- .../GenericKubernetesResourceMatcherTest.java | 32 ++++--- 8 files changed, 134 insertions(+), 41 deletions(-) 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..f1aeadd52a 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,19 @@ */ package io.javaoperatorsdk.operator.api.reconciler; +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.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; @@ -41,6 +45,7 @@ public class DefaultContext

implements Context

{ defaultManagedDependentResourceContext; private final boolean primaryResourceDeleted; private final boolean primaryResourceFinalStateUnknown; + private final Map, Object> desiredStates = new ConcurrentHashMap<>(); public DefaultContext( RetryInfo retryInfo, @@ -157,4 +162,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/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 562a6257b5..5d53b807cc 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 @@ -143,7 +143,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); } @@ -297,7 +297,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() { @@ -305,8 +305,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/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..8a920b28b9 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.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(); From 64c60e7a6f01b89870c8bc9ad393da17deb7497a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 4 Dec 2025 12:07:45 +0100 Subject: [PATCH 09/58] improve: rename junit5 module to junit (#3081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../src/main/resources/templates/pom.xml | 2 +- caffeine-bounded-cache-support/pom.xml | 2 +- .../en/docs/migration/v5-3-migration.md | 29 +++++++ micrometer-support/pom.xml | 2 +- operator-framework-bom/pom.xml | 2 +- .../pom.xml | 4 +- .../junit/AbstractOperatorExtension.java | 0 .../ClusterDeployedOperatorExtension.java | 0 .../junit/DefaultNamespaceNameSupplier.java | 0 .../DefaultPerClassNamespaceNameSupplier.java | 0 .../operator/junit/HasKubernetesClient.java | 0 .../operator/junit/InClusterCurl.java | 0 .../junit/LocallyRunOperatorExtension.java | 0 .../src/test/crd/test.crd | 0 .../DefaultNamespaceNameSupplierTest.java | 0 ...aultPerClassNamespaceNameSupplierTest.java | 0 .../junit/LocallyRunOperatorExtensionIT.java | 0 .../LocallyRunOperatorExtensionTest.java | 0 .../junit/NamespaceNamingTestUtils.java | 0 .../src/test/resources/crd/test.crd | 0 .../src/test/resources/log4j2.xml | 0 .../LocallyRunOperatorExtensionTest.java | 84 ------------------- operator-framework/pom.xml | 2 +- pom.xml | 2 +- .../controller-namespace-deletion/pom.xml | 2 +- sample-operators/leader-election/pom.xml | 2 +- sample-operators/mysql-schema/pom.xml | 2 +- sample-operators/tomcat-operator/pom.xml | 2 +- sample-operators/webpage/pom.xml | 2 +- 29 files changed, 42 insertions(+), 97 deletions(-) create mode 100644 docs/content/en/docs/migration/v5-3-migration.md rename {operator-framework-junit5 => operator-framework-junit}/pom.xml (94%) rename {operator-framework-junit5 => operator-framework-junit}/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/test/crd/test.crd (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java (100%) create mode 100644 operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java create mode 100644 operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java rename {operator-framework-junit5 => operator-framework-junit}/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/test/resources/crd/test.crd (100%) rename {operator-framework-junit5 => operator-framework-junit}/src/test/resources/log4j2.xml (100%) delete mode 100644 operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml b/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml index 5d3451a4a6..9631566a29 100644 --- a/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml +++ b/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml @@ -57,7 +57,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${josdk.version} test diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index a050e0ff3c..ec0a2ef634 100644 --- a/caffeine-bounded-cache-support/pom.xml +++ b/caffeine-bounded-cache-support/pom.xml @@ -43,7 +43,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} test diff --git a/docs/content/en/docs/migration/v5-3-migration.md b/docs/content/en/docs/migration/v5-3-migration.md new file mode 100644 index 0000000000..54007751f9 --- /dev/null +++ b/docs/content/en/docs/migration/v5-3-migration.md @@ -0,0 +1,29 @@ +--- +title: Migrating from v5.2 to v5.3 +description: Migrating from v5.2 to v5.3 +--- + + +## Renamed JUnit Module + +If you use JUnit extension in your test just rename it from: + +``` + + io.javaoperatorsdk + operator-framework-junit-5 + 5.2.x + test + +``` + +to + +``` + + io.javaoperatorsdk + operator-framework-junit + 5.3.0 + test + +``` \ No newline at end of file diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index 93765e4a8a..87ece72ec3 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -58,7 +58,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} test diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 3546390457..93949d794b 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -77,7 +77,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} diff --git a/operator-framework-junit5/pom.xml b/operator-framework-junit/pom.xml similarity index 94% rename from operator-framework-junit5/pom.xml rename to operator-framework-junit/pom.xml index 60c235a9ec..592a243dd9 100644 --- a/operator-framework-junit5/pom.xml +++ b/operator-framework-junit/pom.xml @@ -24,8 +24,8 @@ 5.3.0-SNAPSHOT - operator-framework-junit-5 - Operator SDK - Framework - JUnit 5 extension + operator-framework-junit + Operator SDK - Framework - JUnit extension diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java similarity index 100% rename from operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java rename to operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java diff --git a/operator-framework-junit5/src/test/crd/test.crd b/operator-framework-junit/src/test/crd/test.crd similarity index 100% rename from operator-framework-junit5/src/test/crd/test.crd rename to operator-framework-junit/src/test/crd/test.crd diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java similarity index 100% rename from operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java rename to operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java similarity index 100% rename from operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java rename to operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java diff --git a/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java new file mode 100644 index 0000000000..e69de29bb2 diff --git a/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java new file mode 100644 index 0000000000..e69de29bb2 diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java similarity index 100% rename from operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java rename to operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java diff --git a/operator-framework-junit5/src/test/resources/crd/test.crd b/operator-framework-junit/src/test/resources/crd/test.crd similarity index 100% rename from operator-framework-junit5/src/test/resources/crd/test.crd rename to operator-framework-junit/src/test/resources/crd/test.crd diff --git a/operator-framework-junit5/src/test/resources/log4j2.xml b/operator-framework-junit/src/test/resources/log4j2.xml similarity index 100% rename from operator-framework-junit5/src/test/resources/log4j2.xml rename to operator-framework-junit/src/test/resources/log4j2.xml diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java deleted file mode 100644 index 9491dedf6e..0000000000 --- a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java +++ /dev/null @@ -1,84 +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.junit; - -import java.nio.file.Path; -import java.util.List; - -import org.junit.jupiter.api.Test; - -import io.fabric8.kubernetes.client.KubernetesClientBuilder; - -import static org.junit.jupiter.api.Assertions.*; - -class LocallyRunOperatorExtensionTest { - - @Test - void getAdditionalCRDsFromFiles() { - System.out.println(Path.of("").toAbsolutePath()); - System.out.println(Path.of("src/test/crd/test.crd").toAbsolutePath()); - final var crds = - LocallyRunOperatorExtension.getAdditionalCRDsFromFiles( - List.of("src/test/resources/crd/test.crd", "src/test/crd/test.crd"), - new KubernetesClientBuilder().build()); - assertNotNull(crds); - assertEquals(2, crds.size()); - assertEquals("src/test/crd/test.crd", crds.get("externals.crd.example")); - assertEquals("src/test/resources/crd/test.crd", crds.get("tests.crd.example")); - } - - @Test - void overrideInfrastructureAndUserKubernetesClient() { - var infrastructureClient = new KubernetesClientBuilder().build(); - var userKubernetesClient = new KubernetesClientBuilder().build(); - - LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder() - .withInfrastructureKubernetesClient(infrastructureClient) - .withKubernetesClient(userKubernetesClient) - .build(); - - assertEquals(infrastructureClient, extension.getInfrastructureKubernetesClient()); - assertEquals(userKubernetesClient, extension.getKubernetesClient()); - assertNotEquals(extension.getInfrastructureKubernetesClient(), extension.getKubernetesClient()); - } - - @Test - void overrideInfrastructureAndVerifyUserKubernetesClientIsTheSame() { - var infrastructureClient = new KubernetesClientBuilder().build(); - - LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder() - .withInfrastructureKubernetesClient(infrastructureClient) - .build(); - - assertEquals(infrastructureClient, extension.getInfrastructureKubernetesClient()); - assertEquals(infrastructureClient, extension.getKubernetesClient()); - assertEquals(extension.getInfrastructureKubernetesClient(), extension.getKubernetesClient()); - } - - @Test - void overrideKubernetesClientAndVerifyInfrastructureClientIsTheSame() { - var userKubernetesClient = new KubernetesClientBuilder().build(); - - LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder().withKubernetesClient(userKubernetesClient).build(); - - assertEquals(userKubernetesClient, extension.getKubernetesClient()); - assertEquals(userKubernetesClient, extension.getInfrastructureKubernetesClient()); - assertEquals(extension.getKubernetesClient(), extension.getInfrastructureKubernetesClient()); - } -} diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index 253907eb1e..1c9849dd65 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -92,7 +92,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit ${project.version} test diff --git a/pom.xml b/pom.xml index e63498dd94..0a99cacd1e 100644 --- a/pom.xml +++ b/pom.xml @@ -47,7 +47,7 @@ operator-framework-bom operator-framework-core - operator-framework-junit5 + operator-framework-junit operator-framework micrometer-support sample-operators diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index ddfeebacbc..98b29557ac 100644 --- a/sample-operators/controller-namespace-deletion/pom.xml +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -69,7 +69,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index 70485a2f3e..95d19d4db5 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -69,7 +69,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index a2334ca8c6..7df2a0d417 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -87,7 +87,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index c9fe8c2d06..5719c3a4f1 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -89,7 +89,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index e25920b7da..6ec60340ae 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -68,7 +68,7 @@ io.javaoperatorsdk - operator-framework-junit-5 + operator-framework-junit test From 9a4ad30171f7be01d1970c71f6ae8c64e07816de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 15 Dec 2025 12:09:34 +0100 Subject: [PATCH 10/58] fix: delete empty files result of rebase on main (#3093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../junit/LocallyRunOperatorExtensionIT.java | 15 +++++++++++++++ .../junit/LocallyRunOperatorExtensionTest.java | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java index e69de29bb2..3e1a4f9b14 100644 --- a/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.java +++ b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionIT.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-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java index e69de29bb2..3e1a4f9b14 100644 --- a/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java +++ b/operator-framework-junit/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.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. + */ From fa6049b0f73006ed5a0f1745ed272fe89aee0a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 15 Jan 2026 14:33:16 +0100 Subject: [PATCH 11/58] feat: ReconcileUtils for strongly consistent updates (#3106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds utility that provides methods to update resources using comparable resource versions - Integrates this utility to the core of the framework (thus simplifying `ReconciliationDispatcher`) - note that this introduces a change in the behavior for the `UpdateControl.patchStatus` (and others), since it won't trigger the reconiliation for the event in that update. - Renames former `ReconcilerUtils` to `ReconcilerUtilsInternal`, this is breaking but that utils was never advertised for non-internal usage - Includes also fixes for ControllerEventSource event filtering - Improves TemporaryResourceCache event filtering algorithm Signed-off-by: Attila Mészáros Signed-off-by: Steve Hawkins Co-authored-by: Steve Hawkins --- .../io/javaoperatorsdk/operator/Operator.java | 2 +- ...tils.java => ReconcilerUtilsInternal.java} | 4 +- .../config/AbstractConfigurationService.java | 4 +- .../api/config/BaseConfigurationService.java | 4 +- .../api/config/ControllerConfiguration.java | 12 +- .../informer/InformerConfiguration.java | 4 +- .../operator/api/reconciler/BaseControl.java | 31 + .../PrimaryUpdateAndCacheUtils.java | 4 + .../api/reconciler/ReconcileUtils.java | 717 ++++++++++++++++++ .../KubernetesDependentResource.java | 6 +- .../event/ReconciliationDispatcher.java | 222 +----- .../controller/ControllerEventSource.java | 38 +- .../source/informer/EventFilterDetails.java | 52 ++ .../source/informer/InformerEventSource.java | 46 +- .../source/informer/InformerManager.java | 4 +- .../source/informer/InformerWrapper.java | 6 +- .../informer/ManagedInformerEventSource.java | 74 +- .../informer/TemporaryResourceCache.java | 127 ++-- .../event/source/timer/TimerEventSource.java | 9 +- .../javaoperatorsdk/operator/OperatorIT.java | 2 +- ....java => ReconcilerUtilsInternalTest.java} | 28 +- .../api/reconciler/ReconcileUtilsTest.java | 464 ++++++++++++ .../GenericKubernetesResourceMatcherTest.java | 4 +- .../GenericResourceUpdaterTest.java | 4 +- ...dGenericKubernetesResourceMatcherTest.java | 4 +- .../event/ReconciliationDispatcherTest.java | 214 ++---- .../controller/ControllerEventSourceTest.java | 34 +- .../informer/InformerEventSourceTest.java | 6 +- .../TemporaryPrimaryResourceCacheTest.java | 79 +- .../source/timer/TimerEventSourceTest.java | 10 + .../junit/LocallyRunOperatorExtension.java | 10 +- .../BuiltInResourceCleanerIT.java | 4 +- .../filterpatchevent/FilterPatchEventIT.java | 108 +++ .../FilterPatchEventTestCustomResource.java | 28 + ...terPatchEventTestCustomResourceStatus.java | 30 + .../FilterPatchEventTestReconciler.java | 59 ++ .../InfrastructureClientIT.java | 12 +- .../LeaderElectionPermissionIT.java | 6 +- ...PatchResourceAndStatusNoSSAReconciler.java | 2 +- .../PatchResourceWithSSAReconciler.java | 3 +- .../PatchWithSSAITBase.java | 1 + .../baseapi/simple/ReconcilerExecutorIT.java | 2 +- .../baseapi/simple/TestReconciler.java | 96 +-- .../specupdate/SSASpecUpdateReconciler.java | 18 +- .../subresource/SubResourceUpdateIT.java | 6 +- ...TriggerReconcilerOnAllEventReconciler.java | 6 +- .../SelectiveFinalizerHandlingReconciler.java | 6 +- .../config/BaseConfigurationServiceTest.java | 6 +- .../DefaultConfigurationServiceTest.java | 4 +- .../ExternalStateReconciler.java | 6 +- .../InformerRelatedBehaviorITS.java | 14 +- .../ServiceDependentResource.java | 2 +- .../ServiceDependentResource.java | 2 +- .../StandaloneDependentTestReconciler.java | 4 +- ...lSetDesiredSanitizerDependentResource.java | 4 +- .../dependent/BaseService.java | 4 +- .../dependent/BaseStatefulSet.java | 4 +- .../DeploymentDependentResource.java | 4 +- .../mysql-schema/k8s/operator.yaml | 2 +- .../sample/DeploymentDependentResource.java | 4 +- .../sample/ServiceDependentResource.java | 5 +- .../operator/sample/Utils.java | 2 +- .../operator/sample/WebPageReconciler.java | 7 +- .../DeploymentDependentResource.java | 2 +- .../ServiceDependentResource.java | 2 +- 65 files changed, 2041 insertions(+), 649 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{ReconcilerUtils.java => ReconcilerUtilsInternal.java} (99%) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java rename operator-framework-core/src/test/java/io/javaoperatorsdk/operator/{ReconcilerUtilsTest.java => ReconcilerUtilsInternalTest.java} (84%) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java 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 99% 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..1523b792a5 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 @@ -34,7 +34,7 @@ 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 +46,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); 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/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/InformerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java index 30a1a32e8a..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; @@ -92,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"}) 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/PrimaryUpdateAndCacheUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java index 11dfd21648..31c825e673 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 ReconcileUtils} that contains the more efficient up-to-date versions of + * the target utils. */ +@Deprecated(forRemoval = true) public class PrimaryUpdateAndCacheUtils { public static final int DEFAULT_MAX_RETRY = 10; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java new file mode 100644 index 0000000000..6876fb0f8a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java @@ -0,0 +1,717 @@ +/* + * 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.api.model.ObjectMeta; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; + +import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; +import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; + +public class ReconcileUtils { + + private static final Logger log = LoggerFactory.getLogger(ReconcileUtils.class); + + public static final int DEFAULT_MAX_RETRY = 10; + + private ReconcileUtils() {} + + /** + * Updates the resource and caches the response if needed, thus making sure that next + * reconciliation will contain to updated resource. Or more recent one if someone did an update + * after our update. + * + *

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

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

primary resource type + */ + public static

P serverSideApplyPrimary(Context

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

primary resource type + */ + public static

P serverSideApplyPrimaryStatus( + Context

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

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

P addFinalizer(Context

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

P addFinalizer(Context

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

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

P removeFinalizer(Context

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

P removeFinalizer( + Context

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

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

P conflictRetryingPatch( + Context

context, UnaryOperator

resourceChangesOperator, Predicate

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

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

P addFinalizerWithSSA(Context

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

primary resource type + */ + public static

P addFinalizerWithSSA( + Context

context, String finalizerName) { + var originalResource = context.getPrimaryResource(); + if (log.isDebugEnabled()) { + log.debug( + "Adding finalizer (using SSA) for resource: {} version: {}", + getUID(originalResource), + getVersion(originalResource)); + } + try { + P resource = (P) originalResource.getClass().getConstructor().newInstance(); + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName(originalResource.getMetadata().getName()); + objectMeta.setNamespace(originalResource.getMetadata().getNamespace()); + resource.setMetadata(objectMeta); + resource.addFinalizer(finalizerName); + + return serverSideApplyPrimary(context, resource); + } catch (InstantiationException + | IllegalAccessException + | InvocationTargetException + | NoSuchMethodException e) { + throw new RuntimeException( + "Issue with creating custom resource instance with reflection." + + " Custom Resources must provide a no-arg constructor. Class: " + + originalResource.getClass().getName(), + e); + } + } + + /** + * 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: + * + *

    + *
  1. 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"). + *
  2. 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/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java index 5d53b807cc..b9ea27b190 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 @@ -75,9 +75,8 @@ public void configureWith(KubernetesDependentResourceConfig config) { protected R handleCreate(R desired, P primary, Context

context) { return eventSource() .orElseThrow() - .updateAndCacheResource( + .eventFilteringUpdateAndCacheResource( desired, - context, toCreate -> KubernetesDependentResource.super.handleCreate(toCreate, primary, context)); } @@ -85,9 +84,8 @@ protected R handleCreate(R desired, P primary, Context

context) { protected R handleUpdate(R actual, R desired, P primary, Context

context) { return eventSource() .orElseThrow() - .updateAndCacheResource( + .eventFilteringUpdateAndCacheResource( desired, - context, toUpdate -> KubernetesDependentResource.super.handleUpdate(actual, toUpdate, primary, context)); } 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..82d9a3ed21 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,31 +15,23 @@ */ 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; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.Controller; @@ -49,8 +41,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 +66,6 @@ public ReconciliationDispatcher(Controller

controller) { this( controller, new CustomResourceFacade<>( - controller.getCRClient(), controller.getConfiguration(), controller.getConfiguration().getConfigurationService().getResourceCloner())); } @@ -119,7 +108,7 @@ && shouldNotDispatchToCleanupWhenMarkedForDeletion(originalResource)) { // 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 +137,12 @@ private PostExecutionControl

handleReconcile( */ P updatedResource; if (useSSA) { - updatedResource = addFinalizerWithSSA(originalResource); + updatedResource = ReconcileUtils.addFinalizerWithSSA(context); } else { - updatedResource = updateCustomResourceWithFinalizer(resourceForExecution, originalResource); + updatedResource = ReconcileUtils.addFinalizer(context); } - return PostExecutionControl.onlyFinalizerAdded(updatedResource); + return PostExecutionControl.onlyFinalizerAdded(updatedResource) + .withReSchedule(BaseControl.INSTANT_RESCHEDULE); } else { try { return reconcileExecution(executionScope, resourceForExecution, originalResource, context); @@ -194,7 +184,7 @@ private PostExecutionControl

reconcileExecution( } if (updateControl.isPatchResource()) { - updatedCustomResource = patchResource(toUpdate, originalResource); + updatedCustomResource = patchResource(context, toUpdate, originalResource); if (!useSSA) { toUpdate .getMetadata() @@ -203,7 +193,7 @@ private PostExecutionControl

reconcileExecution( } if (updateControl.isPatchStatus()) { - customResourceFacade.patchStatus(toUpdate, originalResource); + customResourceFacade.patchStatus(context, toUpdate, originalResource); } return createPostExecutionControl(updatedCustomResource, updateControl, executionScope); } @@ -241,7 +231,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 +307,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 +321,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 = ReconcileUtils.removeFinalizer(context); return PostExecutionControl.customResourceFinalizerRemoved(customResource); } } @@ -367,50 +337,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 +352,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 +371,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 +387,28 @@ public R patchResource(R resource, R originalResource) { resource.getMetadata().getResourceVersion()); } if (useSSA) { - return patchResourceWithSSA(resource); + return ReconcileUtils.serverSideApplyPrimary(context, resource); } else { - return resource(originalResource).edit(r -> resource); + return ReconcileUtils.jsonPatchPrimary(context, 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 ReconcileUtils.serverSideApplyPrimaryStatus(context, 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,10 +416,11 @@ private R editStatus(R resource, R originalResource) { try { clonedOriginal.getMetadata().setResourceVersion(null); resource.getMetadata().setResourceVersion(null); - var res = resource(clonedOriginal); - return res.editStatus( + return ReconcileUtils.jsonPatchPrimaryStatus( + context, + clonedOriginal, r -> { - ReconcilerUtils.setStatus(r, ReconcilerUtils.getStatus(resource)); + ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); return r; }); } finally { @@ -571,22 +429,6 @@ private R editStatus(R resource, R originalResource) { 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/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index f7ed9fdc8e..db80c0f4a9 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 @@ -31,8 +31,9 @@ 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.*; @@ -81,21 +82,27 @@ public synchronized void start() { } } - public void eventReceived( - ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) { + @Override + public synchronized void handleEvent( + ResourceAction action, + T resource, + T oldResource, + Boolean deletedFinalStateUnknown, + boolean filterEvent) { try { if (log.isDebugEnabled()) { log.debug( - "Event received for resource: {} version: {} uuid: {} action: {}", + "Event received for resource: {} version: {} uuid: {} action: {} filter event: {}", ResourceID.fromResource(resource), getVersion(resource), resource.getMetadata().getUid(), - action); + action, + filterEvent); log.trace("Event Old resource: {},\n new resource: {}", oldResource, resource); } MDCUtils.addResourceInfo(resource); controller.getEventSourceManager().broadcastOnResourceEvent(action, resource, oldResource); - if (isAcceptedByFilters(action, resource, oldResource)) { + if (isAcceptedByFilters(action, resource, oldResource) && !filterEvent) { if (deletedFinalStateUnknown != null) { getEventHandler() .handleEvent( @@ -132,20 +139,27 @@ private boolean isAcceptedByFilters(ResourceAction action, T resource, T oldReso @Override public void onAdd(T resource) { - super.onAdd(resource); - eventReceived(ResourceAction.ADDED, resource, null, null); + var handling = temporaryResourceCache.onAddOrUpdateEvent(resource); + handleEvent(ResourceAction.ADDED, resource, null, null, handling != EventHandling.NEW); } @Override public void onUpdate(T oldCustomResource, T newCustomResource) { - super.onUpdate(oldCustomResource, newCustomResource); - eventReceived(ResourceAction.UPDATED, newCustomResource, oldCustomResource, null); + var handling = temporaryResourceCache.onAddOrUpdateEvent(newCustomResource); + handleEvent( + ResourceAction.UPDATED, + newCustomResource, + oldCustomResource, + null, + handling != EventHandling.NEW); } @Override public void onDelete(T resource, boolean deletedFinalStateUnknown) { - super.onDelete(resource, deletedFinalStateUnknown); - eventReceived(ResourceAction.DELETED, resource, null, 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, false); } @Override 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..6a2d304976 --- /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.api.reconciler.ReconcileUtils; +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 + || ReconcileUtils.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/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 0feb3dc2a8..247a471df2 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.function.UnaryOperator; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -28,12 +27,13 @@ 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.controller.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION; @@ -98,22 +98,6 @@ private InformerEventSource( genericFilter = informerConfig.getGenericFilter(); } - public R updateAndCacheResource( - R resourceToUpdate, Context context, UnaryOperator updateMethod) { - ResourceID id = ResourceID.fromResource(resourceToUpdate); - if (log.isDebugEnabled()) { - log.debug("Update and cache: {}", id); - } - try { - temporaryResourceCache.startModifying(id); - var updated = updateMethod.apply(resourceToUpdate); - handleRecentResourceUpdate(id, updated, resourceToUpdate); - return updated; - } finally { - temporaryResourceCache.doneModifying(id); - } - } - @Override public void onAdd(R newResource) { if (log.isDebugEnabled()) { @@ -148,12 +132,22 @@ public synchronized 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 + public void handleEvent( + ResourceAction action, + R resource, + R oldResource, + Boolean deletedFinalStateUnknown, + boolean filterEvent) { + propagateEvent(resource); + } + @Override public synchronized void start() { super.start(); @@ -166,10 +160,12 @@ private synchronized void onAddOrUpdate(Operation operation, R newObject, R oldO primaryToSecondaryIndex.onAddOrUpdate(newObject); var resourceID = ResourceID.fromResource(newObject); - if (temporaryResourceCache.onAddOrUpdateEvent(newObject)) { + var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(newObject); + + if (eventHandling != EventHandling.NEW) { log.debug( - "Skipping event propagation for {}, since was a result of a reconcile action. Resource" - + " ID: {}", + "{} event propagation for {}. Resource ID: {}", + eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping", operation, ResourceID.fromResource(newObject)); } else if (eventAcceptedByFilter(operation, newObject, oldObject)) { @@ -233,15 +229,15 @@ public Set getSecondaryResources(P primary) { @Override public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { - handleRecentCreateOrUpdate(Operation.UPDATE, resource, previousVersionOfResource); + handleRecentCreateOrUpdate(resource); } @Override public void handleRecentResourceCreate(ResourceID resourceID, R resource) { - handleRecentCreateOrUpdate(Operation.ADD, resource, null); + handleRecentCreateOrUpdate(resource); } - private void handleRecentCreateOrUpdate(Operation operation, R newResource, R oldResource) { + private void handleRecentCreateOrUpdate(R newResource) { primaryToSecondaryIndex.onAddOrUpdate(newResource); temporaryResourceCache.putResource(newResource); } 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 c3a4a9f2c1..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 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 af30617d92..620edd729e 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; @@ -34,13 +35,15 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Informable; import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; -import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; 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.controller.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; @SuppressWarnings("rawtypes") public abstract class ManagedInformerEventSource< @@ -71,21 +74,6 @@ protected ManagedInformerEventSource( 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; } @@ -97,6 +85,55 @@ 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. + */ + 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.ifPresent( + r -> { + R latestResource = (R) r.getResource().orElseThrow(); + // for update we need to have a historic resource, this might be improved to mimic more + // realistic scenario + R prevVersionOfResource = + updatedForLambda != null + ? updatedForLambda + : (r.getAction() == ResourceAction.UPDATED ? latestResource : null); + handleEvent( + r.getAction(), + latestResource, + prevVersionOfResource, + !(r instanceof ResourceDeleteEvent) + || ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown(), + false); + }); + } + } + + public abstract void handleEvent( + ResourceAction action, + R resource, + R oldResource, + Boolean deletedFinalStateUnknown, + boolean filterEvent); + @SuppressWarnings("unchecked") @Override public synchronized void start() { @@ -137,10 +174,7 @@ public Optional get(ResourceID resourceID) { Optional resource = temporaryResourceCache.getResourceFromCache(resourceID); if (comparableResourceVersions && resource.isPresent() - && res.filter( - r -> - PrimaryUpdateAndCacheUtils.compareResourceVersions(r, resource.orElseThrow()) - > 0) + && res.filter(r -> ReconcileUtils.compareResourceVersions(r, resource.orElseThrow()) > 0) .isEmpty()) { log.debug("Latest resource found in temporary cache for Resource ID: {}", resourceID); return resource; 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 d918be447d..f8254c1bf4 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,18 +15,21 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; import io.javaoperatorsdk.operator.processing.event.ResourceID; +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; /** * Temporal cache is used to solve the problem for {@link KubernetesDependentResource} that is, when @@ -54,83 +57,93 @@ public class TemporaryResourceCache { private final Map cache = new ConcurrentHashMap<>(); private final boolean comparableResourceVersions; - private final Map activelyModifying = new ConcurrentHashMap<>(); private String latestResourceVersion; + private final Map activeUpdates = new HashMap<>(); + + public enum EventHandling { + DEFER, + OBSOLETE, + NEW + } + public TemporaryResourceCache(boolean comparableResourceVersions) { this.comparableResourceVersions = comparableResourceVersions; } - public void startModifying(ResourceID id) { + public synchronized void startEventFilteringModify(ResourceID resourceID) { if (!comparableResourceVersions) { return; } - activelyModifying - .compute( - id, - (ignored, lock) -> { - if (lock != null) { - throw new IllegalStateException(); // concurrent modifications to the same resource - // not allowed - this could be relaxed if needed - } - return new ReentrantLock(); - }) - .lock(); + var ed = activeUpdates.computeIfAbsent(resourceID, id -> new EventFilterDetails()); + ed.increaseActiveUpdates(); } - public void doneModifying(ResourceID id) { + public synchronized Optional doneEventFilterModify( + ResourceID resourceID, String updatedResourceVersion) { if (!comparableResourceVersions) { - return; + return Optional.empty(); + } + var ed = activeUpdates.get(resourceID); + if (ed.decreaseActiveUpdates()) { + activeUpdates.remove(resourceID); + return ed.getLatestEventAfterLastUpdateEvent(updatedResourceVersion); + } else { + return Optional.empty(); } - activelyModifying.computeIfPresent( - id, - (ignored, lock) -> { - lock.unlock(); - return null; - }); } public void onDeleteEvent(T resource, boolean unknownState) { - onEvent(resource, unknownState); + onEvent(resource, unknownState, true); } /** - * @return true if the resourceVersion was already known + * @return true if the resourceVersion was obsolete */ - public boolean onAddOrUpdateEvent(T resource) { - return onEvent(resource, false); + public EventHandling onAddOrUpdateEvent(T resource) { + return onEvent(resource, false, false); } - private boolean onEvent(T resource, boolean unknownState) { - ReentrantLock lock = activelyModifying.get(ResourceID.fromResource(resource)); - if (lock != null) { - lock.lock(); // wait for the modification to finish - lock.unlock(); // simply unlock as the event is guaranteed after the modification + private synchronized EventHandling onEvent(T resource, 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(); } - boolean[] known = new boolean[1]; - synchronized (this) { - if (!unknownState) { - latestResourceVersion = resource.getMetadata().getResourceVersion(); + var cached = cache.get(resourceId); + EventHandling result = EventHandling.NEW; + int comp = 0; + if (cached != null) { + comp = ReconcileUtils.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; } - cache.computeIfPresent( - ResourceID.fromResource(resource), - (id, cached) -> { - boolean remove = unknownState; - if (!unknownState) { - int comp = PrimaryUpdateAndCacheUtils.compareResourceVersions(resource, cached); - if (comp >= 0) { - remove = true; - } - if (comp <= 0) { - known[0] = true; - } - } - if (remove) { - return null; - } - return cached; - }); - return known[0]; + } + var ed = activeUpdates.get(resourceId); + if (ed != null && result != EventHandling.OBSOLETE) { + ed.setLastEvent( + delete + ? new ResourceDeleteEvent(ResourceAction.DELETED, resourceId, resource, unknownState) + : new ResourceEvent( + ResourceAction.UPDATED, resourceId, resource)); // todo true action + return EventHandling.DEFER; + } else { + return result; } } @@ -157,7 +170,7 @@ public synchronized void putResource(T newResource) { // this also prevents resurrecting recently deleted entities for which the delete event // has already been processed if (latestResourceVersion != null - && PrimaryUpdateAndCacheUtils.compareResourceVersions( + && ReconcileUtils.compareResourceVersions( latestResourceVersion, newResource.getMetadata().getResourceVersion()) > 0) { log.debug( @@ -172,7 +185,7 @@ public synchronized void putResource(T newResource) { var cachedResource = getResourceFromCache(resourceId).orElse(null); if (cachedResource == null - || PrimaryUpdateAndCacheUtils.compareResourceVersions(newResource, cachedResource) > 0) { + || ReconcileUtils.compareResourceVersions(newResource, cachedResource) > 0) { log.debug( "Temporarily moving ahead to target version {} for resource id: {}", newResource.getMetadata().getResourceVersion(), 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/ReconcilerUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java similarity index 84% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java rename to operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java index 3bbe2a894b..12e45b9c23 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java @@ -32,17 +32,17 @@ 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 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 ReconcilerUtilsTest { +class ReconcilerUtilsInternalTest { public static final String RESOURCE_URI = "https://kubernetes.docker.internal:6443/apis/tomcatoperator.io/v1/tomcats"; @@ -71,7 +71,7 @@ void equalsSpecObject() { var d1 = createTestDeployment(); var d2 = createTestDeployment(); - assertThat(ReconcilerUtils.specsEqual(d1, d2)).isTrue(); + assertThat(ReconcilerUtilsInternal.specsEqual(d1, d2)).isTrue(); } @Test @@ -80,7 +80,7 @@ void equalArbitraryDifferentSpecsOfObjects() { var d2 = createTestDeployment(); d2.getSpec().getTemplate().getSpec().setHostname("otherhost"); - assertThat(ReconcilerUtils.specsEqual(d1, d2)).isFalse(); + assertThat(ReconcilerUtilsInternal.specsEqual(d1, d2)).isFalse(); } @Test @@ -89,7 +89,7 @@ void getsSpecWithReflection() { deployment.setSpec(new DeploymentSpec()); deployment.getSpec().setReplicas(5); - DeploymentSpec spec = (DeploymentSpec) ReconcilerUtils.getSpec(deployment); + DeploymentSpec spec = (DeploymentSpec) ReconcilerUtilsInternal.getSpec(deployment); assertThat(spec.getReplicas()).isEqualTo(5); } @@ -97,10 +97,10 @@ void getsSpecWithReflection() { void properlyHandlesNullSpec() { Namespace ns = new Namespace(); - final var spec = ReconcilerUtils.getSpec(ns); + final var spec = ReconcilerUtilsInternal.getSpec(ns); assertThat(spec).isNull(); - ReconcilerUtils.setSpec(ns, null); + ReconcilerUtilsInternal.setSpec(ns, null); } @Test @@ -111,7 +111,7 @@ void setsSpecWithReflection() { DeploymentSpec newSpec = new DeploymentSpec(); newSpec.setReplicas(1); - ReconcilerUtils.setSpec(deployment, newSpec); + ReconcilerUtilsInternal.setSpec(deployment, newSpec); assertThat(deployment.getSpec().getReplicas()).isEqualTo(1); } @@ -124,7 +124,7 @@ void setsSpecCustomResourceWithReflection() { TomcatSpec newSpec = new TomcatSpec(); newSpec.setReplicas(1); - ReconcilerUtils.setSpec(tomcat, newSpec); + ReconcilerUtilsInternal.setSpec(tomcat, newSpec); assertThat(tomcat.getSpec().getReplicas()).isEqualTo(1); } @@ -132,7 +132,7 @@ void setsSpecCustomResourceWithReflection() { @Test void loadYamlAsBuilder() { DeploymentBuilder builder = - ReconcilerUtils.loadYaml(DeploymentBuilder.class, getClass(), "deployment.yaml"); + 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(); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java new file mode 100644 index 0000000000..6d8c244c83 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java @@ -0,0 +1,464 @@ +/* + * 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.Disabled; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.PodBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ReconcileUtilsTest { + + private static final Logger log = LoggerFactory.getLogger(ReconcileUtilsTest.class); + private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; + + private Context context; + private KubernetesClient client; + private MixedOperation mixedOperation; + private Resource resourceOp; + private ControllerEventSource controllerEventSource; + private ControllerConfiguration controllerConfiguration; + + @BeforeEach + @SuppressWarnings("unchecked") + void setupMocks() { + context = mock(Context.class); + client = mock(KubernetesClient.class); + mixedOperation = mock(MixedOperation.class); + resourceOp = mock(Resource.class); + controllerEventSource = mock(ControllerEventSource.class); + controllerConfiguration = mock(ControllerConfiguration.class); + + var eventSourceRetriever = mock(EventSourceRetriever.class); + + when(context.getClient()).thenReturn(client); + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME); + when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); + + when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); + when(mixedOperation.inNamespace(any())).thenReturn(mixedOperation); + when(mixedOperation.withName(any())).thenReturn(resourceOp); + } + + @Test + void validateAndCompareResourceVersionsTest() { + assertThat(ReconcileUtils.validateAndCompareResourceVersions("11", "22")).isNegative(); + assertThat(ReconcileUtils.validateAndCompareResourceVersions("22", "11")).isPositive(); + assertThat(ReconcileUtils.validateAndCompareResourceVersions("1", "1")).isZero(); + assertThat(ReconcileUtils.validateAndCompareResourceVersions("11", "11")).isZero(); + assertThat(ReconcileUtils.validateAndCompareResourceVersions("123", "2")).isPositive(); + assertThat(ReconcileUtils.validateAndCompareResourceVersions("3", "211")).isNegative(); + + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("aa", "22")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("11", "ba")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("", "22")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("11", "")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("01", "123")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("123", "01")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("3213", "123a")); + assertThrows( + NonComparableResourceVersionException.class, + () -> ReconcileUtils.validateAndCompareResourceVersions("321", "123a")); + } + + @Test + void compareResourceVersionsWithStrings() { + // Test equal versions + assertThat(ReconcileUtils.compareResourceVersions("1", "1")).isZero(); + assertThat(ReconcileUtils.compareResourceVersions("123", "123")).isZero(); + + // Test different lengths - shorter version is less than longer version + assertThat(ReconcileUtils.compareResourceVersions("1", "12")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("12", "1")).isPositive(); + assertThat(ReconcileUtils.compareResourceVersions("99", "100")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("100", "99")).isPositive(); + assertThat(ReconcileUtils.compareResourceVersions("9", "100")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("100", "9")).isPositive(); + + // Test same length - lexicographic comparison + assertThat(ReconcileUtils.compareResourceVersions("1", "2")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("2", "1")).isPositive(); + assertThat(ReconcileUtils.compareResourceVersions("11", "12")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("12", "11")).isPositive(); + assertThat(ReconcileUtils.compareResourceVersions("99", "100")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("100", "99")).isPositive(); + assertThat(ReconcileUtils.compareResourceVersions("123", "124")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("124", "123")).isPositive(); + + // Test with non-numeric strings (algorithm should still work character-wise) + assertThat(ReconcileUtils.compareResourceVersions("a", "b")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("b", "a")).isPositive(); + assertThat(ReconcileUtils.compareResourceVersions("abc", "abd")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("abd", "abc")).isPositive(); + + // Test edge cases with larger numbers + assertThat(ReconcileUtils.compareResourceVersions("1234567890", "1234567891")).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions("1234567891", "1234567890")).isPositive(); + } + + @Test + void compareResourceVersionsWithHasMetadata() { + // Test equal versions + HasMetadata resource1 = createResourceWithVersion("123"); + HasMetadata resource2 = createResourceWithVersion("123"); + assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isZero(); + + // Test different lengths + resource1 = createResourceWithVersion("1"); + resource2 = createResourceWithVersion("12"); + assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions(resource2, resource1)).isPositive(); + + // Test same length, different values + resource1 = createResourceWithVersion("100"); + resource2 = createResourceWithVersion("200"); + assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions(resource2, resource1)).isPositive(); + + // Test realistic Kubernetes resource versions + resource1 = createResourceWithVersion("12345"); + resource2 = createResourceWithVersion("12346"); + assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isNegative(); + assertThat(ReconcileUtils.compareResourceVersions(resource2, resource1)).isPositive(); + } + + private HasMetadata createResourceWithVersion(String resourceVersion) { + return new PodBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName("test-pod") + .withNamespace("default") + .withResourceVersion(resourceVersion) + .build()) + .build(); + } + + @Test + void addsFinalizer() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful finalizer addition + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void addsFinalizerWithSSA() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful SSA finalizer addition + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + var result = ReconcileUtils.addFinalizerWithSSA(context, FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void removesFinalizer() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + resource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(resource); + + // Mock successful finalizer removal + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + // finalizer is removed, so don't add it + return res; + }); + + var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void retriesAddingFinalizerWithoutSSA() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + when(context.getPrimaryResource()).thenReturn(resource); + + // First call throws conflict, second succeeds + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Conflict", 409, null)) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("2"); + res.addFinalizer(FINALIZER_NAME); + return res; + }); + + // Return fresh resource on retry + when(resourceOp.get()).thenReturn(resource); + + var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); + verify(controllerEventSource, times(2)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + resource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(resource); + + // First call throws conflict + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Conflict", 409, null)); + + // Return null on retry (resource was deleted) + when(resourceOp.get()).thenReturn(null); + + ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + + verify(controllerEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void retriesFinalizerRemovalWithFreshResource() { + var originalResource = TestUtils.testCustomResource1(); + originalResource.getMetadata().setResourceVersion("1"); + originalResource.addFinalizer(FINALIZER_NAME); + + when(context.getPrimaryResource()).thenReturn(originalResource); + + // First call throws unprocessable (422), second succeeds + when(controllerEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class))) + .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) + .thenAnswer( + invocation -> { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setResourceVersion("3"); + // finalizer should be removed + return res; + }); + + // Return fresh resource with newer version on retry + var freshResource = TestUtils.testCustomResource1(); + freshResource.getMetadata().setResourceVersion("2"); + freshResource.addFinalizer(FINALIZER_NAME); + when(resourceOp.get()).thenReturn(freshResource); + + var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + + assertThat(result).isNotNull(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); + assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); + verify(controllerEventSource, times(2)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(resourceOp, times(1)).get(); + } + + @Test + void resourcePatchWithSingleEventSource() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + + var updatedResource = TestUtils.testCustomResource1(); + updatedResource.getMetadata().setResourceVersion("2"); + + var eventSourceRetriever = mock(EventSourceRetriever.class); + var managedEventSource = mock(ManagedInformerEventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(managedEventSource)); + when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + .thenReturn(updatedResource); + + var result = ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity()); + + assertThat(result).isNotNull(); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); + verify(managedEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void resourcePatchThrowsWhenNoEventSourceFound() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(Collections.emptyList()); + + var exception = + assertThrows( + IllegalStateException.class, + () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("No event source found for type"); + } + + @Test + void resourcePatchThrowsWhenMultipleEventSourcesFound() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + var eventSource1 = mock(ManagedInformerEventSource.class); + var eventSource2 = mock(ManagedInformerEventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(eventSource1, eventSource2)); + + var exception = + assertThrows( + IllegalStateException.class, + () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("Multiple event sources found for"); + assertThat(exception.getMessage()).contains("please provide the target event source"); + } + + @Test + void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { + var resource = TestUtils.testCustomResource1(); + var eventSourceRetriever = mock(EventSourceRetriever.class); + var nonManagedEventSource = mock(EventSource.class); + + when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); + when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(nonManagedEventSource)); + + var exception = + assertThrows( + IllegalStateException.class, + () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + + assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); + assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); + } + + // 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 = ReconcileUtils.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); + } +} 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 8a920b28b9..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 @@ -26,7 +26,7 @@ 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; @@ -198,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/ReconciliationDispatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java index cc9df317ae..13673a72d5 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,11 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; -import org.mockito.stubbing.Answer; +import org.mockito.MockedStatic; 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; @@ -46,6 +45,7 @@ import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -56,10 +56,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"}) @@ -154,28 +152,26 @@ public boolean useFinalizer() { @Test void addFinalizerOnNewResource() { - assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - verify(customResourceFacade, times(1)) - .patchResourceWithSSA( - argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER))); + try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizerWithSSA(any()), times(1)); + } } @Test void addFinalizerOnNewResourceWithoutSSA() { - initConfigService(false); - final ReconciliationDispatcher dispatcher = - init(testCustomResource, reconciler, null, customResourceFacade, true); - - assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - 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(); + try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { + initConfigService(false, false); + final ReconciliationDispatcher dispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, true); + + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); + dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizer(any()), times(1)); + } } @Test @@ -190,13 +186,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 +203,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 @@ -232,86 +228,16 @@ void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() { @Test void removesDefaultFinalizerOnDeleteIfSet() { - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); - - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - 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); + try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + markForDeletion(testCustomResource); - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + 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()); + assertThat(postExecControl.isFinalizerRemoved()).isTrue(); + mockedReconcileUtils.verify(() -> ReconcileUtils.removeFinalizer(any()), times(1)); + } } @Test @@ -354,7 +280,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,23 +290,25 @@ 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() { - removeFinalizers(testCustomResource); - reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); - when(customResourceFacade.patchResourceWithSSA(any())).thenReturn(testCustomResource); - - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - verify(customResourceFacade, times(1)) - .patchResourceWithSSA(argThat(a -> !a.getMetadata().getFinalizers().isEmpty())); - assertThat(postExecControl.updateIsStatusPatch()).isFalse(); - assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); + try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { + removeFinalizers(testCustomResource); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + mockedReconcileUtils + .when(() -> ReconcileUtils.addFinalizerWithSSA(any())) + .thenReturn(testCustomResource); + var postExecControl = + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizerWithSSA(any()), times(1)); + assertThat(postExecControl.updateIsStatusPatch()).isFalse(); + assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); + } } @Test @@ -390,7 +318,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 +399,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 +417,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 +457,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 +478,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 +501,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 +523,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 +539,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 +587,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; @@ -751,12 +655,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/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index dcd10b4225..baef2110df 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 @@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test; 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; @@ -46,7 +46,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 +68,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, false); verify(eventHandler, times(1)).handleEvent(any()); - source.eventReceived(ResourceAction.UPDATED, customResource, customResource, null); + source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null, false); verify(eventHandler, times(1)).handleEvent(any()); } @@ -79,12 +79,12 @@ void skipsEventHandlingIfGenerationNotIncreased() { void dontSkipEventHandlingIfMarkedForDeletion() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); 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, false); verify(eventHandler, times(2)).handleEvent(any()); } @@ -92,11 +92,11 @@ void dontSkipEventHandlingIfMarkedForDeletion() { void normalExecutionIfGenerationChanges() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setGeneration(2L); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); verify(eventHandler, times(2)).handleEvent(any()); } @@ -107,10 +107,10 @@ void handlesAllEventIfNotGenerationAware() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); verify(eventHandler, times(1)).handleEvent(any()); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); verify(eventHandler, times(2)).handleEvent(any()); } @@ -118,7 +118,7 @@ void handlesAllEventIfNotGenerationAware() { void eventWithNoGenerationProcessedIfNoFinalizer() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); verify(eventHandler, times(1)).handleEvent(any()); } @@ -127,7 +127,7 @@ void eventWithNoGenerationProcessedIfNoFinalizer() { void callsBroadcastsOnResourceEvents() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); verify(testController.getEventSourceManager(), times(1)) .broadcastOnResourceEvent( @@ -143,8 +143,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, false); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null, false); verify(eventHandler, never()).handleEvent(any()); } @@ -156,9 +156,9 @@ 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, false); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null, false); + source.handleEvent(ResourceAction.DELETED, cr, cr, true, false); verify(eventHandler, never()).handleEvent(any()); } 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 f54e47304b..0fc721cccb 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 @@ -36,6 +36,7 @@ import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_NAMESPACES_SET; @@ -98,7 +99,7 @@ void skipsEventPropagation() { when(temporaryResourceCacheMock.getResourceFromCache(any())) .thenReturn(Optional.of(testDeployment())); - when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(true); + when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.OBSOLETE); informerEventSource.onAdd(testDeployment()); informerEventSource.onUpdate(testDeployment(), testDeployment()); @@ -108,6 +109,7 @@ void skipsEventPropagation() { @Test void processEventPropagationWithoutAnnotation() { + when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.NEW); informerEventSource.onUpdate(testDeployment(), testDeployment()); verify(eventHandlerMock, times(1)).handleEvent(any()); @@ -115,6 +117,7 @@ void processEventPropagationWithoutAnnotation() { @Test void processEventPropagationWithIncorrectAnnotation() { + when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.NEW); informerEventSource.onAdd( new DeploymentBuilder(testDeployment()) .editMetadata() @@ -131,6 +134,7 @@ void propagateEventAndRemoveResourceFromTempCacheIfResourceVersionMismatch() { cachedDeployment.getMetadata().setResourceVersion(PREV_RESOURCE_VERSION); when(temporaryResourceCacheMock.getResourceFromCache(any())) .thenReturn(Optional.of(cachedDeployment)); + when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.NEW); informerEventSource.onUpdate(cachedDeployment, testDeployment()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java index 4b12148015..4c5d137fd3 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java @@ -21,14 +21,17 @@ import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; 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.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; class TemporaryPrimaryResourceCacheTest { @@ -118,11 +121,12 @@ void nonComparableResourceVersionsDisables() { .isEmpty(); } + @Disabled("todo") @Test void lockedEventBeforePut() throws Exception { var testResource = testResource(); - temporaryResourceCache.startModifying(ResourceID.fromResource(testResource)); + temporaryResourceCache.startEventFilteringModify(ResourceID.fromResource(testResource)); ExecutorService ex = Executors.newSingleThreadExecutor(); try { @@ -130,8 +134,8 @@ void lockedEventBeforePut() throws Exception { temporaryResourceCache.putResource(testResource); assertThat(result.isDone()).isFalse(); - temporaryResourceCache.doneModifying(ResourceID.fromResource(testResource)); - assertThat(result.get(10, TimeUnit.SECONDS)).isTrue(); + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "3"); + assertThat(result.get(10, TimeUnit.SECONDS)).isEqualTo(EventHandling.NEW); } finally { ex.shutdownNow(); } @@ -143,15 +147,78 @@ void putBeforeEvent() { // first ensure an event is not known var result = temporaryResourceCache.onAddOrUpdateEvent(testResource); - assertThat(result).isFalse(); + assertThat(result).isEqualTo(EventHandling.NEW); var nextResource = testResource(); nextResource.getMetadata().setResourceVersion("3"); temporaryResourceCache.putResource(nextResource); - // now expect an event with the matching resourceVersion to be known after the put + // the result is obsolete result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); - assertThat(result).isTrue(); + assertThat(result).isEqualTo(EventHandling.OBSOLETE); + } + + @Test + void putBeforeEventWithEventFiltering() { + var testResource = testResource(); + + // first ensure an event is not known + var result = temporaryResourceCache.onAddOrUpdateEvent(testResource); + assertThat(result).isEqualTo(EventHandling.NEW); + + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("3"); + var resourceId = ResourceID.fromResource(testResource); + + temporaryResourceCache.startEventFilteringModify(resourceId); + temporaryResourceCache.putResource(nextResource); + temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + + // the result is obsolete + result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + assertThat(result).isEqualTo(EventHandling.OBSOLETE); + } + + @Test + void putAfterEventWithEventFilteringNoPost() { + var testResource = testResource(); + + // first ensure an event is not known + var result = temporaryResourceCache.onAddOrUpdateEvent(testResource); + assertThat(result).isEqualTo(EventHandling.NEW); + + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("3"); + var resourceId = ResourceID.fromResource(testResource); + + temporaryResourceCache.startEventFilteringModify(resourceId); + result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + // the result is deferred + assertThat(result).isEqualTo(EventHandling.DEFER); + temporaryResourceCache.putResource(nextResource); + var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + + // there is no post event because the done call claimed responsibility for rv 3 + assertTrue(postEvent.isEmpty()); + } + + @Test + void putAfterEventWithEventFilteringWithPost() { + var testResource = testResource(); + var resourceId = ResourceID.fromResource(testResource); + temporaryResourceCache.startEventFilteringModify(resourceId); + + // this should be a corner case - watch had a hard reset since the start of the + // of the update operation, such that 4 rv event is seen prior to the update + // completing with the 3 rv. + var nextResource = testResource(); + nextResource.getMetadata().setResourceVersion("4"); + var result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + assertThat(result).isEqualTo(EventHandling.DEFER); + + var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + + assertTrue(postEvent.isPresent()); } @Test diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java index f444a5e2ba..3a4e1cb80d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import io.javaoperatorsdk.operator.TestUtils; +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.EventHandler; @@ -115,6 +116,15 @@ public void eventNotFiredIfStopped() { assertThat(source.getStatus()).isEqualTo(Status.UNHEALTHY); } + @Test + public void handlesInstanceReschedule() { + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); + + source.scheduleOnce(resourceID, BaseControl.INSTANT_RESCHEDULE); + + assertThat(eventHandler.events).hasSize(1); + } + private void untilAsserted(ThrowingRunnable assertion) { untilAsserted(INITIAL_DELAY, PERIOD, assertion); } diff --git a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java index 5ea82026c3..a1cdef8309 100644 --- a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -44,7 +44,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.LocalPortForward; import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.RegisteredController; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; @@ -143,7 +143,7 @@ public static Builder builder() { } public static void applyCrd(Class resourceClass, KubernetesClient client) { - applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client); + applyCrd(ReconcilerUtilsInternal.getResourceTypeName(resourceClass), client); } /** @@ -195,7 +195,7 @@ private static void applyCrd(String crdString, String path, KubernetesClient cli * @param crClass the custom resource class for which we want to apply the CRD */ public void applyCrd(Class crClass) { - applyCrd(ReconcilerUtils.getResourceTypeName(crClass)); + applyCrd(ReconcilerUtilsInternal.getResourceTypeName(crClass)); } public void applyCrd(CustomResourceDefinition customResourceDefinition) { @@ -233,7 +233,7 @@ private void applyCrdFromMappings(String pathAsString, String resourceTypeName) * * @param resourceTypeName the resource type name associated with the CRD to be applied, * typically, given a resource type, its name would be obtained using {@link - * ReconcilerUtils#getResourceTypeName(Class)} + * ReconcilerUtilsInternal#getResourceTypeName(Class)} */ public void applyCrd(String resourceTypeName) { // first attempt to use a manually defined CRD @@ -321,7 +321,7 @@ protected void before(ExtensionContext context) { ref.controllerConfigurationOverrider.accept(oconfig); } - final var resourceTypeName = ReconcilerUtils.getResourceTypeName(resourceClass); + final var resourceTypeName = ReconcilerUtilsInternal.getResourceTypeName(resourceClass); // only try to apply a CRD for the reconciler if it is associated to a CR if (CustomResource.class.isAssignableFrom(resourceClass)) { applyCrd(resourceTypeName); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java index 9667c22486..18e076e2bf 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java @@ -24,7 +24,7 @@ import io.fabric8.kubernetes.api.model.Service; import io.javaoperatorsdk.annotation.Sample; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.dependent.standalonedependent.StandaloneDependentResourceIT; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; @@ -85,7 +85,7 @@ void cleanerIsCalledOnBuiltInResource() { Service testService() { Service service = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Service.class, StandaloneDependentResourceIT.class, "/io/javaoperatorsdk/operator/service-template.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java new file mode 100644 index 0000000000..6f27925e21 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java @@ -0,0 +1,108 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.filterpatchevent; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Controlling patch event filtering in UpdateControl", + description = + """ + Demonstrates how to use the filterPatchEvent parameter in UpdateControl to control \ + whether patch operations trigger subsequent reconciliation events. When filterPatchEvent \ + is true (default), patch events are filtered out to prevent reconciliation loops. When \ + false, patch events trigger reconciliation, allowing for controlled event propagation. + """) +class FilterPatchEventIT { + + public static final int POLL_DELAY = 150; + public static final String NAME = "test1"; + public static final String UPDATED = "updated"; + + FilterPatchEventTestReconciler reconciler = new FilterPatchEventTestReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void patchEventFilteredWhenFlagIsTrue() { + reconciler.setFilterPatchEvent(true); + var resource = createTestResource(); + extension.create(resource); + + // Wait for the reconciliation to complete and the resource to be updated + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> { + var updated = extension.get(FilterPatchEventTestCustomResource.class, NAME); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getValue()).isEqualTo(UPDATED); + }); + + // With filterPatchEvent=true, reconciliation should only run once + // (triggered by the initial create, but not by the patch operation) + int executions = reconciler.getNumberOfExecutions(); + assertThat(executions).isEqualTo(1); + } + + @Test + void patchEventNotFilteredWhenFlagIsFalse() { + reconciler.setFilterPatchEvent(false); + var resource = createTestResource(); + extension.create(resource); + + // Wait for the reconciliation to complete and the resource to be updated + await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + var updated = extension.get(FilterPatchEventTestCustomResource.class, NAME); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getValue()).isEqualTo(UPDATED); + }); + + // Wait for potential additional reconciliations + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + int executions = reconciler.getNumberOfExecutions(); + // With filterPatchEvent=false, reconciliation should run at least twice + // (once for create and at least once for the patch event) + assertThat(executions).isGreaterThanOrEqualTo(2); + }); + } + + private FilterPatchEventTestCustomResource createTestResource() { + FilterPatchEventTestCustomResource resource = new FilterPatchEventTestCustomResource(); + resource.setMetadata(new ObjectMeta()); + resource.getMetadata().setName(NAME); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java new file mode 100644 index 0000000000..7f8b4838de --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java @@ -0,0 +1,28 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.filterpatchevent; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("fpe") +public class FilterPatchEventTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java new file mode 100644 index 0000000000..1c7aeafadd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java @@ -0,0 +1,30 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.filterpatchevent; + +public class FilterPatchEventTestCustomResourceStatus { + + private String value; + + public String getValue() { + return value; + } + + public FilterPatchEventTestCustomResourceStatus setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java new file mode 100644 index 0000000000..e7599a2881 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java @@ -0,0 +1,59 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.filterpatchevent; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +import static io.javaoperatorsdk.operator.baseapi.filterpatchevent.FilterPatchEventIT.UPDATED; + +@ControllerConfiguration(generationAwareEventProcessing = false) +public class FilterPatchEventTestReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final AtomicBoolean filterPatchEvent = new AtomicBoolean(false); + + @Override + public UpdateControl reconcile( + FilterPatchEventTestCustomResource resource, + Context context) { + numberOfExecutions.incrementAndGet(); + + // Update the spec value to trigger a patch operation + resource.setStatus(new FilterPatchEventTestCustomResourceStatus()); + resource.getStatus().setValue(UPDATED); + + var uc = UpdateControl.patchStatus(resource); + if (!filterPatchEvent.get()) { + uc = uc.reschedule(); + } + return uc; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public void setFilterPatchEvent(boolean b) { + filterPatchEvent.set(b); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java index 59faaae90b..eb39fa0657 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java @@ -28,7 +28,7 @@ import io.fabric8.kubernetes.client.ConfigBuilder; import io.fabric8.kubernetes.client.KubernetesClientBuilder; import io.fabric8.kubernetes.client.KubernetesClientException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; import static org.assertj.core.api.Assertions.assertThat; @@ -127,23 +127,25 @@ void shouldNotAccessNotPermittedResources() { private void applyClusterRoleBinding(String filename) { var clusterRoleBinding = - ReconcilerUtils.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); + ReconcilerUtilsInternal.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRoleBinding).serverSideApply(); } private void applyClusterRole(String filename) { - var clusterRole = ReconcilerUtils.loadYaml(ClusterRole.class, this.getClass(), filename); + var clusterRole = + ReconcilerUtilsInternal.loadYaml(ClusterRole.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRole).serverSideApply(); } private void removeClusterRoleBinding(String filename) { var clusterRoleBinding = - ReconcilerUtils.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); + ReconcilerUtilsInternal.loadYaml(ClusterRoleBinding.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRoleBinding).delete(); } private void removeClusterRole(String filename) { - var clusterRole = ReconcilerUtils.loadYaml(ClusterRole.class, this.getClass(), filename); + var clusterRole = + ReconcilerUtilsInternal.loadYaml(ClusterRole.class, this.getClass(), filename); operator.getInfrastructureKubernetesClient().resource(clusterRole).delete(); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java index 0180e3b8b8..6b5cbcc812 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionpermission/LeaderElectionPermissionIT.java @@ -26,7 +26,7 @@ import io.javaoperatorsdk.annotation.Sample; import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; @@ -87,14 +87,14 @@ public UpdateControl reconcile(ConfigMap resource, Context private void applyRoleBinding() { var clusterRoleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( RoleBinding.class, this.getClass(), "leader-elector-stop-noaccess-role-binding.yaml"); adminClient.resource(clusterRoleBinding).createOrReplace(); } private void applyRole() { var role = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Role.class, this.getClass(), "leader-elector-stop-role-noaccess.yaml"); adminClient.resource(role).createOrReplace(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java index e091896597..eb19f9e249 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java @@ -45,7 +45,7 @@ public UpdateControl reconcile( Context context) { numberOfExecutions.addAndGet(1); - log.info("Value: " + resource.getSpec().getValue()); + log.info("Value: {}", resource.getSpec().getValue()); if (removeAnnotation) { resource.getMetadata().getAnnotations().remove(TEST_ANNOTATION); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java index c241c4cd4f..a252115b80 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java @@ -41,7 +41,8 @@ public UpdateControl reconcile( if (resource.getSpec().getControllerManagedValue() == null) { res.setSpec(new PatchResourceWithSSASpec()); res.getSpec().setControllerManagedValue(ADDED_VALUE); - return UpdateControl.patchResource(res); + // test assumes we will run this in the next reconciliation + return UpdateControl.patchResource(res).reschedule(); } else { res.setStatus(new PatchResourceWithSSAStatus()); res.getStatus().setSuccessfullyReconciled(true); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java index 9f2ca81543..2a8314ecb9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java @@ -49,6 +49,7 @@ void reconcilerPatchesResourceWithSSA() { .isEqualTo(PatchResourceWithSSAReconciler.ADDED_VALUE); // finalizer is added to the SSA patch in the background by the framework assertThat(actualResource.getMetadata().getFinalizers()).isNotEmpty(); + assertThat(actualResource.getStatus()).isNotNull(); assertThat(actualResource.getStatus().isSuccessfullyReconciled()).isTrue(); // one for resource, one for subresource assertThat( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java index b6790e4085..54d639c05a 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java @@ -52,7 +52,7 @@ void configMapGetsCreatedForTestCustomResource() { awaitResourcesCreatedOrUpdated(); awaitStatusUpdated(); - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java index b614b97f3a..49dbe80554 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.baseapi.simple; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -25,8 +26,11 @@ 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.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; @ControllerConfiguration(generationAwareEventProcessing = false) @@ -38,7 +42,7 @@ public class TestReconciler private static final Logger log = LoggerFactory.getLogger(TestReconciler.class); public static final String FINALIZER_NAME = - ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class); + ReconcilerUtilsInternal.getDefaultFinalizerName(TestCustomResource.class); private final AtomicInteger numberOfExecutions = new AtomicInteger(0); private final AtomicInteger numberOfCleanupExecutions = new AtomicInteger(0); @@ -52,32 +56,6 @@ public void setUpdateStatus(boolean updateStatus) { this.updateStatus = updateStatus; } - @Override - public DeleteControl cleanup(TestCustomResource resource, Context context) { - numberOfCleanupExecutions.incrementAndGet(); - - var statusDetail = - context - .getClient() - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getSpec().getConfigMapName()) - .delete(); - - if (statusDetail.size() == 1 && statusDetail.get(0).getCauses().isEmpty()) { - log.info( - "Deleted ConfigMap {} for resource: {}", - resource.getSpec().getConfigMapName(), - resource.getMetadata().getName()); - } else { - log.error( - "Failed to delete ConfigMap {} for resource: {}", - resource.getSpec().getConfigMapName(), - resource.getMetadata().getName()); - } - return DeleteControl.defaultDelete(); - } - @Override public UpdateControl reconcile( TestCustomResource resource, Context context) { @@ -85,22 +63,13 @@ public UpdateControl reconcile( if (!resource.getMetadata().getFinalizers().contains(FINALIZER_NAME)) { throw new IllegalStateException("Finalizer is not present."); } - final var kubernetesClient = context.getClient(); - ConfigMap existingConfigMap = - kubernetesClient - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getSpec().getConfigMapName()) - .get(); + + var existingConfigMap = context.getSecondaryResource(ConfigMap.class).orElse(null); if (existingConfigMap != null) { existingConfigMap.setData(configMapData(resource)); - // existingConfigMap.getMetadata().setResourceVersion(null); - kubernetesClient - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(existingConfigMap) - .createOrReplace(); + log.info("Updating config map"); + ReconcileUtils.serverSideApply(context, existingConfigMap); } else { Map labels = new HashMap<>(); labels.put("managedBy", TestReconciler.class.getSimpleName()); @@ -114,11 +83,8 @@ public UpdateControl reconcile( .build()) .withData(configMapData(resource)) .build(); - kubernetesClient - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(newConfigMap) - .createOrReplace(); + log.info("Creating config map"); + ReconcileUtils.serverSideApply(context, newConfigMap); } if (updateStatus) { var statusUpdateResource = new TestCustomResource(); @@ -129,11 +95,49 @@ public UpdateControl reconcile( .build()); resource.setStatus(new TestCustomResourceStatus()); resource.getStatus().setConfigMapStatus("ConfigMap Ready"); + log.info("Patching status"); return UpdateControl.patchStatus(resource); } return UpdateControl.noUpdate(); } + @Override + public DeleteControl cleanup(TestCustomResource resource, Context context) { + numberOfCleanupExecutions.incrementAndGet(); + + var statusDetail = + context + .getClient() + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getSpec().getConfigMapName()) + .delete(); + + if (statusDetail.size() == 1 && statusDetail.get(0).getCauses().isEmpty()) { + log.info( + "Deleted ConfigMap {} for resource: {}", + resource.getSpec().getConfigMapName(), + resource.getMetadata().getName()); + } else { + log.error( + "Failed to delete ConfigMap {} for resource: {}", + resource.getSpec().getConfigMapName(), + resource.getMetadata().getName()); + } + return DeleteControl.defaultDelete(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + InformerEventSource es = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(ConfigMap.class, TestCustomResource.class) + .build(), + context); + return List.of(es); + } + private Map configMapData(TestCustomResource resource) { Map data = new HashMap<>(); data.put(resource.getSpec().getKey(), resource.getSpec().getValue()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java index c1dca492ca..ccdbfdd181 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java @@ -15,6 +15,9 @@ */ package io.javaoperatorsdk.operator.baseapi.ssaissue.specupdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.operator.api.reconciler.Cleaner; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -27,18 +30,21 @@ public class SSASpecUpdateReconciler implements Reconciler, Cleaner { + private static final Logger log = LoggerFactory.getLogger(SSASpecUpdateReconciler.class); + @Override public UpdateControl reconcile( SSASpecUpdateCustomResource resource, Context context) { var copy = createFreshCopy(resource); copy.getSpec().setValue("value"); - context - .getClient() - .resource(copy) - .fieldManager(context.getControllerConfiguration().fieldManager()) - .serverSideApply(); - + var res = + context + .getClient() + .resource(copy) + .fieldManager(context.getControllerConfiguration().fieldManager()) + .serverSideApply(); + log.info("res: {}", res); return UpdateControl.noUpdate(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java index 1ea9ca96ce..a86220439c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java @@ -59,7 +59,7 @@ void updatesSubResourceStatus() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } @Test @@ -73,7 +73,7 @@ void updatesSubResourceStatusNoFinalizer() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } /** Note that we check on controller impl if there is finalizer on execution. */ @@ -87,7 +87,7 @@ void ifNoFinalizerPresentFirstAddsTheFinalizerThenExecutesControllerAgain() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } /** diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java index 0b8c0ff1e6..2217662402 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java @@ -22,7 +22,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -75,7 +75,7 @@ public UpdateControl reconcile( if (!primary.isMarkedForDeletion() && getUseFinalizer() && !primary.hasFinalizer(FINALIZER)) { log.info("Adding finalizer"); - PrimaryUpdateAndCacheUtils.addFinalizer(context, FINALIZER); + ReconcileUtils.addFinalizer(context, FINALIZER); return UpdateControl.noUpdate(); } @@ -98,7 +98,7 @@ public UpdateControl reconcile( setEventOnMarkedForDeletion(true); if (getUseFinalizer() && primary.hasFinalizer(FINALIZER)) { log.info("Removing finalizer"); - PrimaryUpdateAndCacheUtils.removeFinalizer(context, FINALIZER); + ReconcileUtils.removeFinalizer(context, FINALIZER); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java index 9b3cd5683f..f9198d0eae 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java @@ -17,7 +17,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -37,11 +37,11 @@ public UpdateControl reconci } if (resource.getSpec().getUseFinalizer()) { - PrimaryUpdateAndCacheUtils.addFinalizer(context, FINALIZER); + ReconcileUtils.addFinalizer(context, FINALIZER); } if (resource.isMarkedForDeletion()) { - PrimaryUpdateAndCacheUtils.removeFinalizer(context, FINALIZER); + ReconcileUtils.removeFinalizer(context, FINALIZER); } return UpdateControl.noUpdate(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java index 370f09509f..ffd0f6b904 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java @@ -29,7 +29,7 @@ import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.Service; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.AnnotationConfigurable; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; import io.javaoperatorsdk.operator.api.config.dependent.ConfigurationConverter; @@ -133,13 +133,13 @@ void missingAnnotationCreatesDefaultConfig() { final var reconciler = new MissingAnnotationReconciler(); var config = configFor(reconciler); - assertThat(config.getName()).isEqualTo(ReconcilerUtils.getNameFor(reconciler)); + assertThat(config.getName()).isEqualTo(ReconcilerUtilsInternal.getNameFor(reconciler)); assertThat(config.getRetry()).isInstanceOf(GenericRetry.class); assertThat(config.getRateLimiter()).isInstanceOf(LinearRateLimiter.class); assertThat(config.maxReconciliationInterval()).hasValue(Duration.ofHours(DEFAULT_INTERVAL)); assertThat(config.fieldManager()).isEqualTo(config.getName()); assertThat(config.getFinalizerName()) - .isEqualTo(ReconcilerUtils.getDefaultFinalizerName(config.getResourceClass())); + .isEqualTo(ReconcilerUtilsInternal.getDefaultFinalizerName(config.getResourceClass())); final var informerConfig = config.getInformerConfig(); assertThat(informerConfig.getLabelSelector()).isNull(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java index 1b328ccaf9..fa31575b9e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java @@ -20,7 +20,7 @@ import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.model.annotation.Group; import io.fabric8.kubernetes.model.annotation.Version; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; @@ -40,7 +40,7 @@ void returnsValuesFromControllerAnnotationFinalizer() { assertEquals( CustomResource.getCRDName(TestCustomResource.class), configuration.getResourceTypeName()); assertEquals( - ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class), + ReconcilerUtilsInternal.getDefaultFinalizerName(TestCustomResource.class), configuration.getFinalizerName()); assertEquals(TestCustomResource.class, configuration.getResourceClass()); assertFalse(configuration.isGenerationAware()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java index 89d1dee94b..5a9d9a7f06 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java @@ -109,10 +109,8 @@ private void createExternalResource( // Making sure that the created resources are in the cache for the next reconciliation. // This is critical in this case, since on next reconciliation if it would not be in the cache // it would be created again. - configMapEventSource.updateAndCacheResource( - configMap, - context, - toCreate -> context.getClient().configMaps().resource(toCreate).create()); + configMapEventSource.eventFilteringUpdateAndCacheResource( + configMap, toCreate -> context.getClient().configMaps().resource(toCreate).create()); externalResourceEventSource.handleRecentResourceCreate(primaryID, createdResource); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java index 221d7363a3..ce98af58e0 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java @@ -34,7 +34,7 @@ import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; @@ -399,23 +399,25 @@ private void setFullResourcesAccess() { private void addRoleBindingsToTestNamespaces() { var role = - ReconcilerUtils.loadYaml(Role.class, this.getClass(), "rbac-test-only-main-ns-access.yaml"); + ReconcilerUtilsInternal.loadYaml( + Role.class, this.getClass(), "rbac-test-only-main-ns-access.yaml"); adminClient.resource(role).inNamespace(actualNamespace).createOrReplace(); var roleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( RoleBinding.class, this.getClass(), "rbac-test-only-main-ns-access-binding.yaml"); adminClient.resource(roleBinding).inNamespace(actualNamespace).createOrReplace(); } private void applyClusterRoleBinding() { var clusterRoleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( ClusterRoleBinding.class, this.getClass(), "rbac-test-role-binding.yaml"); adminClient.resource(clusterRoleBinding).createOrReplace(); } private void applyClusterRole(String filename) { - var clusterRole = ReconcilerUtils.loadYaml(ClusterRole.class, this.getClass(), filename); + var clusterRole = + ReconcilerUtilsInternal.loadYaml(ClusterRole.class, this.getClass(), filename); adminClient.resource(clusterRole).createOrReplace(); } @@ -431,7 +433,7 @@ private Namespace namespace(String name) { private void removeClusterRoleBinding() { var clusterRoleBinding = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( ClusterRoleBinding.class, this.getClass(), "rbac-test-role-binding.yaml"); adminClient.resource(clusterRoleBinding).delete(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java index 1bb34de16c..fb243251f3 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java @@ -26,7 +26,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; @KubernetesDependent public class ServiceDependentResource diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java index 7cd65bd7ef..6a998b3ea4 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java @@ -25,7 +25,7 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; @KubernetesDependent public class ServiceDependentResource diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java index 6f97be1be7..92f033d681 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java @@ -20,7 +20,7 @@ import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.client.KubernetesClientException; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; @@ -90,7 +90,7 @@ protected Deployment desired( StandaloneDependentTestCustomResource primary, Context context) { Deployment deployment = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Deployment.class, StandaloneDependentResourceIT.class, "/io/javaoperatorsdk/operator/nginx-deployment.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java index e86c772cda..e4bcaac460 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java @@ -17,7 +17,7 @@ import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.apps.StatefulSet; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; @@ -32,7 +32,7 @@ protected StatefulSet desired( StatefulSetDesiredSanitizerCustomResource primary, Context context) { var template = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( StatefulSet.class, getClass(), "/io/javaoperatorsdk/operator/statefulset.yaml"); template.setMetadata( new ObjectMetaBuilder() diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java index 06abcc0889..7a0d50debf 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java @@ -19,7 +19,7 @@ import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.ServiceBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; @@ -33,7 +33,7 @@ public BaseService(String component) { protected Service desired( ComplexWorkflowCustomResource primary, Context context) { var template = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Service.class, getClass(), "/io/javaoperatorsdk/operator/workflow/complexdependent/service.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java index b0a7b60805..1e4aa73e80 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java @@ -19,7 +19,7 @@ import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; @@ -32,7 +32,7 @@ public BaseStatefulSet(String component) { protected StatefulSet desired( ComplexWorkflowCustomResource primary, Context context) { var template = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( StatefulSet.class, getClass(), "/io/javaoperatorsdk/operator/workflow/complexdependent/statefulset.yaml"); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java index b9aa595b76..e5c7f726f5 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java @@ -16,7 +16,7 @@ package io.javaoperatorsdk.operator.workflow.workflowallfeature; import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; @@ -27,7 +27,7 @@ public class DeploymentDependentResource protected Deployment desired( WorkflowAllFeatureCustomResource primary, Context context) { Deployment deployment = - ReconcilerUtils.loadYaml( + ReconcilerUtilsInternal.loadYaml( Deployment.class, WorkflowAllFeatureIT.class, "/io/javaoperatorsdk/operator/nginx-deployment.yaml"); diff --git a/sample-operators/mysql-schema/k8s/operator.yaml b/sample-operators/mysql-schema/k8s/operator.yaml index a6f1214e34..10543900e9 100644 --- a/sample-operators/mysql-schema/k8s/operator.yaml +++ b/sample-operators/mysql-schema/k8s/operator.yaml @@ -39,7 +39,7 @@ spec: serviceAccountName: mysql-schema-operator # specify the ServiceAccount under which's RBAC persmissions the operator will be executed under containers: - name: operator - image: mysql-schema-operator # TODO Change this to point to your pushed mysql-schema-operator image + image: mysql-schema-operator # Change this to point to your pushed mysql-schema-operator image imagePullPolicy: IfNotPresent ports: - containerPort: 80 diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java index 0347b726ac..c4a47069e2 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java @@ -18,7 +18,7 @@ import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.informer.Informer; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; @@ -36,7 +36,7 @@ private static String tomcatImage(Tomcat tomcat) { @Override protected Deployment desired(Tomcat tomcat, Context context) { Deployment deployment = - ReconcilerUtils.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + ReconcilerUtilsInternal.loadYaml(Deployment.class, getClass(), "deployment.yaml"); final ObjectMeta tomcatMetadata = tomcat.getMetadata(); final String tomcatName = tomcatMetadata.getName(); deployment = diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java index 72f430528e..bcb0e80026 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java @@ -18,7 +18,7 @@ import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.ServiceBuilder; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.informer.Informer; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; @@ -31,7 +31,8 @@ public class ServiceDependentResource extends CRUDKubernetesDependentResource context) { final ObjectMeta tomcatMetadata = tomcat.getMetadata(); - return new ServiceBuilder(ReconcilerUtils.loadYaml(Service.class, getClass(), "service.yaml")) + return new ServiceBuilder( + ReconcilerUtilsInternal.loadYaml(Service.class, getClass(), "service.yaml")) .editMetadata() .withName(tomcatMetadata.getName()) .withNamespace(tomcatMetadata.getNamespace()) diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java index ab4ed8a337..ecfe66d329 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java @@ -21,7 +21,7 @@ import io.javaoperatorsdk.operator.sample.customresource.WebPage; import io.javaoperatorsdk.operator.sample.customresource.WebPageStatus; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; public class Utils { diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index 94b460474f..941a159542 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -27,7 +27,7 @@ import io.fabric8.kubernetes.api.model.*; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; -import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.*; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -219,7 +219,8 @@ private boolean match(ConfigMap desiredHtmlConfigMap, ConfigMap existingConfigMa } private Service makeDesiredService(WebPage webPage, String ns, Deployment desiredDeployment) { - Service desiredService = ReconcilerUtils.loadYaml(Service.class, getClass(), "service.yaml"); + Service desiredService = + ReconcilerUtilsInternal.loadYaml(Service.class, getClass(), "service.yaml"); desiredService.getMetadata().setName(serviceName(webPage)); desiredService.getMetadata().setNamespace(ns); desiredService.getMetadata().setLabels(lowLevelLabel()); @@ -233,7 +234,7 @@ private Service makeDesiredService(WebPage webPage, String ns, Deployment desire private Deployment makeDesiredDeployment( WebPage webPage, String deploymentName, String ns, String configMapName) { Deployment desiredDeployment = - ReconcilerUtils.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + ReconcilerUtilsInternal.loadYaml(Deployment.class, getClass(), "deployment.yaml"); desiredDeployment.getMetadata().setName(deploymentName); desiredDeployment.getMetadata().setNamespace(ns); desiredDeployment.getMetadata().setLabels(lowLevelLabel()); diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java index 6d1f7cc911..e383633ab1 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java @@ -27,7 +27,7 @@ import io.javaoperatorsdk.operator.sample.Utils; import io.javaoperatorsdk.operator.sample.customresource.WebPage; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; import static io.javaoperatorsdk.operator.sample.Utils.configMapName; import static io.javaoperatorsdk.operator.sample.Utils.deploymentName; import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java index 3dbc784887..02204d415a 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java @@ -25,7 +25,7 @@ import io.javaoperatorsdk.operator.sample.Utils; import io.javaoperatorsdk.operator.sample.customresource.WebPage; -import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; +import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.loadYaml; import static io.javaoperatorsdk.operator.sample.Utils.deploymentName; import static io.javaoperatorsdk.operator.sample.Utils.serviceName; import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; From 2b4795fb56fd6ec4d1cfe47d3ed36131906211b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 21 Jan 2026 13:24:23 +0100 Subject: [PATCH 12/58] Event filtering now records resource action and previous resource (#3127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/EventProcessor.java | 2 +- .../processing/event/EventSourceManager.java | 2 +- .../{controller => }/ResourceAction.java | 2 +- .../controller/ControllerEventSource.java | 50 ++--- .../controller/ResourceDeleteEvent.java | 1 + .../source/controller/ResourceEvent.java | 1 + .../informer/ExtendedResourceEvent.java | 42 +++++ .../source/informer/InformerEventSource.java | 35 ++-- .../informer/ManagedInformerEventSource.java | 41 +++-- .../informer/TemporaryResourceCache.java | 22 ++- .../javaoperatorsdk/operator/TestUtils.java | 8 +- .../processing/event/EventProcessorTest.java | 2 +- .../event/ResourceStateManagerTest.java | 2 +- .../event/source/EventFilterTestUtils.java | 64 +++++++ .../controller/ControllerEventSourceTest.java | 132 ++++++++++++-- .../informer/InformerEventSourceTest.java | 172 ++++++++++++++++-- .../TemporaryPrimaryResourceCacheTest.java | 109 ++++++++--- 17 files changed, 546 insertions(+), 141 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/{controller => }/ResourceAction.java (90%) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/EventFilterTestUtils.java 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/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 db80c0f4a9..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,6 +28,7 @@ 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; @@ -83,26 +84,21 @@ public synchronized void start() { } @Override - public synchronized void handleEvent( - ResourceAction action, - T resource, - T oldResource, - Boolean deletedFinalStateUnknown, - boolean filterEvent) { + protected synchronized void handleEvent( + ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) { try { if (log.isDebugEnabled()) { log.debug( - "Event received for resource: {} version: {} uuid: {} action: {} filter event: {}", + "Event received for resource: {} version: {} uuid: {} action: {}", ResourceID.fromResource(resource), getVersion(resource), resource.getMetadata().getUid(), - action, - filterEvent); + action); log.trace("Event Old resource: {},\n new resource: {}", oldResource, resource); } MDCUtils.addResourceInfo(resource); controller.getEventSourceManager().broadcastOnResourceEvent(action, resource, oldResource); - if (isAcceptedByFilters(action, resource, oldResource) && !filterEvent) { + if (isAcceptedByFilters(action, resource, oldResource)) { if (deletedFinalStateUnknown != null) { getEventHandler() .handleEvent( @@ -138,28 +134,36 @@ private boolean isAcceptedByFilters(ResourceAction action, T resource, T oldReso } @Override - public void onAdd(T resource) { - var handling = temporaryResourceCache.onAddOrUpdateEvent(resource); - handleEvent(ResourceAction.ADDED, resource, null, null, handling != EventHandling.NEW); + public synchronized void onAdd(T resource) { + handleOnAddOrUpdate(ResourceAction.ADDED, null, resource); } @Override - public void onUpdate(T oldCustomResource, T newCustomResource) { - var handling = temporaryResourceCache.onAddOrUpdateEvent(newCustomResource); - handleEvent( - ResourceAction.UPDATED, - newCustomResource, - oldCustomResource, - null, - handling != EventHandling.NEW); + 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) { + 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, false); + 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/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 247a471df2..6743ff436a 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 @@ -32,7 +32,7 @@ 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.controller.ResourceAction; +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; @@ -107,7 +107,7 @@ public void onAdd(R newResource) { resourceType().getSimpleName(), newResource.getMetadata().getResourceVersion()); } - onAddOrUpdate(Operation.ADD, newResource, null); + onAddOrUpdate(ResourceAction.ADDED, newResource, null); } @Override @@ -120,7 +120,7 @@ public void onUpdate(R oldObject, R newObject) { newObject.getMetadata().getResourceVersion(), oldObject.getMetadata().getResourceVersion()); } - onAddOrUpdate(Operation.UPDATE, newObject, oldObject); + onAddOrUpdate(ResourceAction.UPDATED, newObject, oldObject); } @Override @@ -139,12 +139,8 @@ public synchronized void onDelete(R resource, boolean b) { } @Override - public void handleEvent( - ResourceAction action, - R resource, - R oldResource, - Boolean deletedFinalStateUnknown, - boolean filterEvent) { + protected void handleEvent( + ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown) { propagateEvent(resource); } @@ -156,27 +152,27 @@ public synchronized void start() { manager().list().forEach(primaryToSecondaryIndex::onAddOrUpdate); } - private synchronized void onAddOrUpdate(Operation operation, R newObject, R oldObject) { + private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) { primaryToSecondaryIndex.onAddOrUpdate(newObject); var resourceID = ResourceID.fromResource(newObject); - var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(newObject); + var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); if (eventHandling != EventHandling.NEW) { log.debug( "{} event propagation for {}. Resource ID: {}", eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping", - operation, + action, ResourceID.fromResource(newObject)); - } else if (eventAcceptedByFilter(operation, newObject, oldObject)) { + } else if (eventAcceptedByFilter(action, newObject, oldObject)) { log.debug( "Propagating event for {}, resource with same version not result of a reconciliation." + " Resource ID: {}", - operation, + action, resourceID); propagateEvent(newObject); } else { - log.debug("Event filtered out for operation: {}, resourceID: {}", operation, resourceID); + log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } } @@ -251,11 +247,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); @@ -266,9 +262,4 @@ private boolean acceptedByDeleteFilters(R resource, boolean b) { return (onDeleteFilter == null || onDeleteFilter.accept(resource, b)) && (genericFilter == null || genericFilter.accept(resource)); } - - private enum Operation { - ADD, - UPDATE - } } 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 620edd729e..9278400dde 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 @@ -42,7 +42,7 @@ 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.controller.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; @SuppressWarnings("rawtypes") @@ -90,6 +90,7 @@ public void changeNamespaces(Set namespaces) { * 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()) { @@ -107,32 +108,38 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< id, updatedResource == null ? null : updatedResource.getMetadata().getResourceVersion()); var updatedForLambda = updatedResource; - res.ifPresent( + res.ifPresentOrElse( r -> { R latestResource = (R) r.getResource().orElseThrow(); - // for update we need to have a historic resource, this might be improved to mimic more - // realistic scenario + + // 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 - : (r.getAction() == ResourceAction.UPDATED ? latestResource : null); + updatedForLambda != null ? updatedForLambda : extendedResourcePrevVersion; handleEvent( r.getAction(), latestResource, prevVersionOfResource, - !(r instanceof ResourceDeleteEvent) - || ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown(), - false); - }); + (r instanceof ResourceDeleteEvent) + ? ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown() + : null); + }, + () -> log.debug("No new event present after the filtering update; id: {}", id)); } } - public abstract void handleEvent( - ResourceAction action, - R resource, - R oldResource, - Boolean deletedFinalStateUnknown, - boolean filterEvent); + protected abstract void handleEvent( + ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown); @SuppressWarnings("unchecked") @Override 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 f8254c1bf4..eb76387a80 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 @@ -27,7 +27,7 @@ import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; import io.javaoperatorsdk.operator.processing.event.ResourceID; -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; @@ -94,17 +94,23 @@ public synchronized Optional doneEventFilterModify( } public void onDeleteEvent(T resource, boolean unknownState) { - onEvent(resource, unknownState, true); + onEvent(ResourceAction.DELETED, resource, null, unknownState, true); } /** * @return true if the resourceVersion was obsolete */ - public EventHandling onAddOrUpdateEvent(T resource) { - return onEvent(resource, false, false); + public EventHandling onAddOrUpdateEvent( + ResourceAction action, T resource, T prevResourceVersion) { + return onEvent(action, resource, prevResourceVersion, false, false); } - private synchronized EventHandling onEvent(T resource, boolean unknownState, boolean delete) { + private synchronized EventHandling onEvent( + ResourceAction action, + T resource, + T prevResourceVersion, + boolean unknownState, + boolean delete) { if (!comparableResourceVersions) { return EventHandling.NEW; } @@ -121,9 +127,8 @@ private synchronized EventHandling onEvent(T resource, boolean unknownState, boo } var cached = cache.get(resourceId); EventHandling result = EventHandling.NEW; - int comp = 0; if (cached != null) { - comp = ReconcileUtils.compareResourceVersions(resource, cached); + int comp = ReconcileUtils.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 @@ -139,8 +144,7 @@ private synchronized EventHandling onEvent(T resource, boolean unknownState, boo ed.setLastEvent( delete ? new ResourceDeleteEvent(ResourceAction.DELETED, resourceId, resource, unknownState) - : new ResourceEvent( - ResourceAction.UPDATED, resourceId, resource)); // todo true action + : new ExtendedResourceEvent(action, resourceId, resource, prevResourceVersion)); return EventHandling.DEFER; } else { return result; 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/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/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 baef2110df..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,10 +17,12 @@ 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.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.TestUtils; @@ -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.*; @@ -68,10 +75,10 @@ void skipsEventHandlingIfGenerationNotIncreased() { TestCustomResource oldCustomResource = TestUtils.testCustomResource(); oldCustomResource.getMetadata().setFinalizers(List.of(FINALIZER)); - source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null, false); + source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null, false); + 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.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); // mark for deletion customResource1.getMetadata().setDeletionTimestamp(LocalDateTime.now().toString()); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + 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.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setGeneration(2L); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -107,10 +114,10 @@ void handlesAllEventIfNotGenerationAware() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + 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.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + 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.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, false); + 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.handleEvent(ResourceAction.ADDED, cr, null, null, false); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null, false); + 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.handleEvent(ResourceAction.ADDED, cr, null, null, false); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null, false); - source.handleEvent(ResourceAction.DELETED, cr, cr, true, false); + 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 0fc721cccb..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,17 +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; @@ -58,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 informerEventSourceConfiguration = @@ -74,11 +85,12 @@ void setup() { when(informerEventSourceConfiguration.getResourceClass()).thenReturn(Deployment.class); informerEventSource = - new InformerEventSource<>(informerEventSourceConfiguration, clientMock) { - // mocking start - @Override - public synchronized void start() {} - }; + spy( + new InformerEventSource<>(informerEventSourceConfiguration, clientMock) { + // mocking start + @Override + public synchronized void start() {} + }); var mockControllerConfig = mock(ControllerConfiguration.class); when(mockControllerConfig.getConfigurationService()).thenReturn(new BaseConfigurationService()); @@ -91,15 +103,16 @@ public synchronized void start() {} when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) .thenReturn(Set.of(ResourceID.fromResource(testDeployment()))); informerEventSource.start(); - informerEventSource.setTemporalResourceCache(temporaryResourceCacheMock); + informerEventSource.setTemporalResourceCache(temporaryResourceCache); } @Test void skipsEventPropagation() { - when(temporaryResourceCacheMock.getResourceFromCache(any())) + when(temporaryResourceCache.getResourceFromCache(any())) .thenReturn(Optional.of(testDeployment())); - when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.OBSOLETE); + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.OBSOLETE); informerEventSource.onAdd(testDeployment()); informerEventSource.onUpdate(testDeployment(), testDeployment()); @@ -109,7 +122,8 @@ void skipsEventPropagation() { @Test void processEventPropagationWithoutAnnotation() { - when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.NEW); + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.NEW); informerEventSource.onUpdate(testDeployment(), testDeployment()); verify(eventHandlerMock, times(1)).handleEvent(any()); @@ -117,7 +131,8 @@ void processEventPropagationWithoutAnnotation() { @Test void processEventPropagationWithIncorrectAnnotation() { - when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.NEW); + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.NEW); informerEventSource.onAdd( new DeploymentBuilder(testDeployment()) .editMetadata() @@ -130,22 +145,22 @@ void processEventPropagationWithIncorrectAnnotation() { @Test void propagateEventAndRemoveResourceFromTempCacheIfResourceVersionMismatch() { + withRealTemporaryResourceCache(); + Deployment cachedDeployment = testDeployment(); cachedDeployment.getMetadata().setResourceVersion(PREV_RESOURCE_VERSION); - when(temporaryResourceCacheMock.getResourceFromCache(any())) - .thenReturn(Optional.of(cachedDeployment)); - when(temporaryResourceCacheMock.onAddOrUpdateEvent(any())).thenReturn(EventHandling.NEW); + temporaryResourceCache.putResource(cachedDeployment); informerEventSource.onUpdate(cachedDeployment, testDeployment()); verify(eventHandlerMock, times(1)).handleEvent(any()); - verify(temporaryResourceCacheMock, times(1)).onAddOrUpdateEvent(testDeployment()); + verify(temporaryResourceCache, times(1)).onAddOrUpdateEvent(any(), eq(testDeployment()), any()); } @Test void genericFilterForEvents() { informerEventSource.setGenericFilter(r -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onAdd(testDeployment()); informerEventSource.onUpdate(testDeployment(), testDeployment()); @@ -157,7 +172,7 @@ void genericFilterForEvents() { @Test void filtersOnAddEvents() { informerEventSource.setOnAddFilter(r -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onAdd(testDeployment()); @@ -167,7 +182,7 @@ void filtersOnAddEvents() { @Test void filtersOnUpdateEvents() { informerEventSource.setOnUpdateFilter((r1, r2) -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onUpdate(testDeployment(), testDeployment()); @@ -177,13 +192,132 @@ void filtersOnUpdateEvents() { @Test void filtersOnDeleteEvents() { informerEventSource.setOnDeleteFilter((r, b) -> false); - when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + when(temporaryResourceCache.getResourceFromCache(any())).thenReturn(Optional.empty()); informerEventSource.onDelete(testDeployment(), true); verify(eventHandlerMock, never()).handleEvent(any()); } + @Test + void handlesPrevResourceVersionForUpdate() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch.countDown(); + + expectHandleEvent(3, 2); + } + + @Test + void handlesPrevResourceVersionForUpdateInCaseOfException() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = + EventFilterTestUtils.sendForEventFilteringUpdate( + informerEventSource, + testDeployment(), + r -> { + throw new KubernetesClientException("fake"); + }); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + + expectHandleEvent(2, 1); + } + + @Test + void handlesPrevResourceVersionForUpdateInCaseOfMultipleUpdates() { + withRealTemporaryResourceCache(); + + var deployment = testDeployment(); + CountDownLatch latch = sendForEventFilteringUpdate(deployment, 2); + informerEventSource.onUpdate( + withResourceVersion(testDeployment(), 2), withResourceVersion(testDeployment(), 3)); + informerEventSource.onUpdate( + withResourceVersion(testDeployment(), 3), withResourceVersion(testDeployment(), 4)); + latch.countDown(); + + expectHandleEvent(4, 2); + } + + @Test + void doesNotPropagateEventIfReceivedBeforeUpdate() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + + assertNoEventProduced(); + } + + @Test + void filterAddEventBeforeUpdate() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + informerEventSource.onAdd(deploymentWithResourceVersion(1)); + latch.countDown(); + + assertNoEventProduced(); + } + + private void assertNoEventProduced() { + await() + .pollDelay(Duration.ofMillis(50)) + .timeout(Duration.ofMillis(51)) + .untilAsserted( + () -> { + verify(informerEventSource, never()).handleEvent(any(), any(), any(), any()); + }); + } + + private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { + await() + .untilAsserted( + () -> { + verify(informerEventSource, times(1)) + .handleEvent( + eq(ResourceAction.UPDATED), + argThat( + newResource -> { + assertThat(newResource.getMetadata().getResourceVersion()) + .isEqualTo("" + newResourceVersion); + return true; + }), + argThat( + newResource -> { + assertThat(newResource.getMetadata().getResourceVersion()) + .isEqualTo("" + oldResourceVersion); + return true; + }), + isNull()); + }); + } + + private CountDownLatch sendForEventFilteringUpdate(int resourceVersion) { + return sendForEventFilteringUpdate(testDeployment(), resourceVersion); + } + + private CountDownLatch sendForEventFilteringUpdate(Deployment deployment, int resourceVersion) { + return EventFilterTestUtils.sendForEventFilteringUpdate( + informerEventSource, deployment, r -> withResourceVersion(deployment, resourceVersion)); + } + + private void withRealTemporaryResourceCache() { + temporaryResourceCache = spy(new TemporaryResourceCache<>(true)); + informerEventSource.setTemporalResourceCache(temporaryResourceCache); + } + + Deployment deploymentWithResourceVersion(int resourceVersion) { + return withResourceVersion(testDeployment(), resourceVersion); + } + @Test void informerStoppedHandlerShouldBeCalledWhenInformerStops() { final var exception = new RuntimeException("Informer stopped exceptionally!"); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java index 4c5d137fd3..592a552433 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java @@ -16,18 +16,15 @@ package io.javaoperatorsdk.operator.processing.event.source.informer; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; 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.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import static org.assertj.core.api.Assertions.assertThat; @@ -61,7 +58,9 @@ void updateNotAddsTheResourceIntoCacheIfLaterVersionKnown() { var testResource = testResource(); temporaryResourceCache.onAddOrUpdateEvent( - testResource.toBuilder().editMetadata().withResourceVersion("3").endMetadata().build()); + ResourceAction.ADDED, + testResource.toBuilder().editMetadata().withResourceVersion("3").endMetadata().build(), + null); temporaryResourceCache.putResource(testResource); @@ -101,11 +100,13 @@ void removesResourceFromCache() { ConfigMap testResource = propagateTestResourceToCache(); temporaryResourceCache.onAddOrUpdateEvent( + ResourceAction.ADDED, new ConfigMapBuilder(testResource) .editMetadata() .withResourceVersion("3") .endMetadata() - .build()); + .build(), + null); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) .isNotPresent(); @@ -121,24 +122,72 @@ void nonComparableResourceVersionsDisables() { .isEmpty(); } - @Disabled("todo") @Test - void lockedEventBeforePut() throws Exception { + void eventReceivedDuringFiltering() throws Exception { + var testResource = testResource(); + + temporaryResourceCache.startEventFilteringModify(ResourceID.fromResource(testResource)); + + temporaryResourceCache.putResource(testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + + var doneRes = + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + + assertThat(doneRes).isEmpty(); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + } + + @Test + void newerEventDuringFiltering() { + var testResource = testResource(); + + temporaryResourceCache.startEventFilteringModify(ResourceID.fromResource(testResource)); + + temporaryResourceCache.putResource(testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + var testResource2 = testResource(); + testResource2.getMetadata().setResourceVersion("3"); + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, testResource2, testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + + var doneRes = + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + + assertThat(doneRes).isPresent(); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + } + + @Test + void eventAfterFiltering() { var testResource = testResource(); temporaryResourceCache.startEventFilteringModify(ResourceID.fromResource(testResource)); - ExecutorService ex = Executors.newSingleThreadExecutor(); - try { - var result = ex.submit(() -> temporaryResourceCache.onAddOrUpdateEvent(testResource)); - - temporaryResourceCache.putResource(testResource); - assertThat(result.isDone()).isFalse(); - temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "3"); - assertThat(result.get(10, TimeUnit.SECONDS)).isEqualTo(EventHandling.NEW); - } finally { - ex.shutdownNow(); - } + temporaryResourceCache.putResource(testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + var doneRes = + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + + assertThat(doneRes).isEmpty(); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); } @Test @@ -146,7 +195,8 @@ void putBeforeEvent() { var testResource = testResource(); // first ensure an event is not known - var result = temporaryResourceCache.onAddOrUpdateEvent(testResource); + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); assertThat(result).isEqualTo(EventHandling.NEW); var nextResource = testResource(); @@ -154,7 +204,7 @@ void putBeforeEvent() { temporaryResourceCache.putResource(nextResource); // the result is obsolete - result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); assertThat(result).isEqualTo(EventHandling.OBSOLETE); } @@ -163,7 +213,8 @@ void putBeforeEventWithEventFiltering() { var testResource = testResource(); // first ensure an event is not known - var result = temporaryResourceCache.onAddOrUpdateEvent(testResource); + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); assertThat(result).isEqualTo(EventHandling.NEW); var nextResource = testResource(); @@ -175,7 +226,7 @@ void putBeforeEventWithEventFiltering() { temporaryResourceCache.doneEventFilterModify(resourceId, "3"); // the result is obsolete - result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); assertThat(result).isEqualTo(EventHandling.OBSOLETE); } @@ -184,7 +235,8 @@ void putAfterEventWithEventFilteringNoPost() { var testResource = testResource(); // first ensure an event is not known - var result = temporaryResourceCache.onAddOrUpdateEvent(testResource); + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); assertThat(result).isEqualTo(EventHandling.NEW); var nextResource = testResource(); @@ -192,7 +244,9 @@ void putAfterEventWithEventFilteringNoPost() { var resourceId = ResourceID.fromResource(testResource); temporaryResourceCache.startEventFilteringModify(resourceId); - result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + result = + temporaryResourceCache.onAddOrUpdateEvent( + ResourceAction.UPDATED, nextResource, testResource); // the result is deferred assertThat(result).isEqualTo(EventHandling.DEFER); temporaryResourceCache.putResource(nextResource); @@ -213,7 +267,8 @@ void putAfterEventWithEventFilteringWithPost() { // completing with the 3 rv. var nextResource = testResource(); nextResource.getMetadata().setResourceVersion("4"); - var result = temporaryResourceCache.onAddOrUpdateEvent(nextResource); + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, nextResource, null); assertThat(result).isEqualTo(EventHandling.DEFER); var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId, "3"); @@ -225,7 +280,7 @@ void putAfterEventWithEventFilteringWithPost() { void rapidDeletion() { var testResource = testResource(); - temporaryResourceCache.onAddOrUpdateEvent(testResource); + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); temporaryResourceCache.onDeleteEvent( new ConfigMapBuilder(testResource) .editMetadata() From 120a28bf6763f42fff75fc42eb47a0d13a16d093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 27 Jan 2026 12:09:12 +0100 Subject: [PATCH 13/58] improve: facelift samples to use ReconcileUtils (#3135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../changenamespace/ChangeNamespaceIT.java | 2 +- .../ChangeNamespaceTestReconciler.java | 10 +------ ...cKubernetesResourceHandlingReconciler.java | 28 +------------------ ...ultipleSecondaryEventSourceReconciler.java | 28 ++----------------- .../ExternalStateReconciler.java | 3 +- .../operator/sample/WebPageReconciler.java | 19 +++---------- 6 files changed, 12 insertions(+), 78 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java index 592e40100e..4a32d97252 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java @@ -131,7 +131,7 @@ private static void assertReconciled( assertThat( reconciler.numberOfResourceReconciliations( resourceInAdditionalTestNamespace)) - .isEqualTo(2)); + .isEqualTo(1)); } private static void assertNotReconciled( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index 64a80ff4a8..96bd43c9e2 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -53,15 +53,7 @@ public UpdateControl reconcile( ChangeNamespaceTestCustomResource primary, Context context) { - var actualConfigMap = context.getSecondaryResource(ConfigMap.class); - if (actualConfigMap.isEmpty()) { - context - .getClient() - .configMaps() - .inNamespace(primary.getMetadata().getNamespace()) - .resource(configMap(primary)) - .create(); - } + ReconcileUtils.serverSideApply(context, configMap(primary)); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java index 039faf056c..f76443c103 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java @@ -17,7 +17,6 @@ import java.io.IOException; import java.io.InputStream; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -41,36 +40,11 @@ public UpdateControl reconcile( GenericKubernetesResourceHandlingCustomResource primary, Context context) { - var secondary = context.getSecondaryResource(GenericKubernetesResource.class); - - secondary.ifPresentOrElse( - r -> { - var desired = desiredConfigMap(primary, context); - if (!matches(r, desired)) { - context - .getClient() - .genericKubernetesResources(VERSION, KIND) - .resource(desired) - .update(); - } - }, - () -> - context - .getClient() - .genericKubernetesResources(VERSION, KIND) - .resource(desiredConfigMap(primary, context)) - .create()); + ReconcileUtils.serverSideApply(context, desiredConfigMap(primary, context)); return UpdateControl.noUpdate(); } - @SuppressWarnings("unchecked") - private boolean matches(GenericKubernetesResource actual, GenericKubernetesResource desired) { - var actualData = (HashMap) actual.getAdditionalProperties().get("data"); - var desiredData = (HashMap) desired.getAdditionalProperties().get("data"); - return actualData.equals(desiredData); - } - GenericKubernetesResource desiredConfigMap( GenericKubernetesResourceHandlingCustomResource primary, Context context) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java index 7409d5a5e4..aea2dfe0c2 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java @@ -26,6 +26,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -53,31 +54,8 @@ public UpdateControl reconcile( Context context) { numberOfExecutions.addAndGet(1); - final var client = context.getClient(); - if (client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(getName1(resource)) - .get() - == null) { - client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(configMap(getName1(resource), resource)) - .createOrReplace(); - } - if (client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(getName2(resource)) - .get() - == null) { - client - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .resource(configMap(getName2(resource), resource)) - .createOrReplace(); - } + ReconcileUtils.serverSideApply(context, configMap(getName1(resource), resource)); + ReconcileUtils.serverSideApply(context, configMap(getName2(resource), resource)); if (numberOfExecutions.get() >= 3) { if (context.getSecondaryResources(ConfigMap.class).size() != 2) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java index 5a9d9a7f06..b97d8ef679 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java @@ -32,6 +32,7 @@ import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -110,7 +111,7 @@ private void createExternalResource( // This is critical in this case, since on next reconciliation if it would not be in the cache // it would be created again. configMapEventSource.eventFilteringUpdateAndCacheResource( - configMap, toCreate -> context.getClient().configMaps().resource(toCreate).create()); + configMap, toCreate -> ReconcileUtils.serverSideApply(context, toCreate)); externalResourceEventSource.handleRecentResourceCreate(primaryID, createdResource); } diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index 941a159542..13fede9fcc 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -105,12 +105,7 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating ConfigMap {} in {}", desiredHtmlConfigMap.getMetadata().getName(), ns); - context - .getClient() - .configMaps() - .inNamespace(ns) - .resource(desiredHtmlConfigMap) - .serverSideApply(); + ReconcileUtils.serverSideApply(context, desiredHtmlConfigMap); } var existingDeployment = context.getSecondaryResource(Deployment.class).orElse(null); @@ -119,13 +114,7 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating Deployment {} in {}", desiredDeployment.getMetadata().getName(), ns); - context - .getClient() - .apps() - .deployments() - .inNamespace(ns) - .resource(desiredDeployment) - .serverSideApply(); + ReconcileUtils.serverSideApply(context, desiredDeployment); } var existingService = context.getSecondaryResource(Service.class).orElse(null); @@ -134,14 +123,14 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating Deployment {} in {}", desiredDeployment.getMetadata().getName(), ns); - context.getClient().services().inNamespace(ns).resource(desiredService).serverSideApply(); + ReconcileUtils.serverSideApply(context, desiredService); } var existingIngress = context.getSecondaryResource(Ingress.class); if (Boolean.TRUE.equals(webPage.getSpec().getExposed())) { var desiredIngress = makeDesiredIngress(webPage); if (existingIngress.isEmpty() || !match(desiredIngress, existingIngress.get())) { - context.getClient().resource(desiredIngress).inNamespace(ns).serverSideApply(); + ReconcileUtils.serverSideApply(context, desiredIngress); } } else existingIngress.ifPresent(ingress -> context.getClient().resource(ingress).delete()); From 4d384fd2fcf5a2d4cf783ce38b7d086ea9ae5dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 28 Jan 2026 08:14:42 +0100 Subject: [PATCH 14/58] improve: move compare resource version methods to internal utils (#3137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This should not be user facing. At least not in any obvious scenerio. Signed-off-by: Attila Mészáros --- .../operator/ReconcilerUtilsInternal.java | 120 +++++++++++++++ .../api/reconciler/ReconcileUtils.java | 119 --------------- .../source/informer/EventFilterDetails.java | 4 +- .../informer/ManagedInformerEventSource.java | 5 +- .../informer/TemporaryResourceCache.java | 8 +- .../operator/ReconcilerUtilsInternalTest.java | 137 +++++++++++++++++- .../api/reconciler/ReconcileUtilsTest.java | 136 ----------------- 7 files changed, 265 insertions(+), 264 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java index 1523b792a5..26ae5af554 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternal.java @@ -31,6 +31,7 @@ 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") @@ -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: + * + *

    + *
  1. 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"). + *
  2. 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/reconciler/ReconcileUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java index 6876fb0f8a..ed02c56a01 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java @@ -595,123 +595,4 @@ public static

P addFinalizerWithSSA( e); } } - - /** - * 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: - * - *

    - *
  1. 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"). - *
  2. 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/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java index 6a2d304976..8b573a986c 100644 --- 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 @@ -17,7 +17,7 @@ import java.util.Optional; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; class EventFilterDetails { @@ -41,7 +41,7 @@ public void setLastEvent(ResourceEvent event) { public Optional getLatestEventAfterLastUpdateEvent(String updatedResourceVersion) { if (lastEvent != null && (updatedResourceVersion == null - || ReconcileUtils.compareResourceVersions( + || ReconcilerUtilsInternal.compareResourceVersions( lastEvent.getResource().orElseThrow().getMetadata().getResourceVersion(), updatedResourceVersion) > 0)) { 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 9278400dde..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 @@ -32,10 +32,10 @@ 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; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; @@ -181,7 +181,8 @@ public Optional get(ResourceID resourceID) { Optional resource = temporaryResourceCache.getResourceFromCache(resourceID); if (comparableResourceVersions && resource.isPresent() - && res.filter(r -> ReconcileUtils.compareResourceVersions(r, resource.orElseThrow()) > 0) + && res.filter( + r -> ReconcilerUtilsInternal.compareResourceVersions(r, resource.orElseThrow()) > 0) .isEmpty()) { log.debug("Latest resource found in temporary cache for Resource ID: {}", resourceID); return resource; 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 eb76387a80..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 @@ -24,7 +24,7 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; +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; @@ -128,7 +128,7 @@ private synchronized EventHandling onEvent( var cached = cache.get(resourceId); EventHandling result = EventHandling.NEW; if (cached != null) { - int comp = ReconcileUtils.compareResourceVersions(resource, cached); + 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 @@ -174,7 +174,7 @@ public synchronized void putResource(T newResource) { // this also prevents resurrecting recently deleted entities for which the delete event // has already been processed if (latestResourceVersion != null - && ReconcileUtils.compareResourceVersions( + && ReconcilerUtilsInternal.compareResourceVersions( latestResourceVersion, newResource.getMetadata().getResourceVersion()) > 0) { log.debug( @@ -189,7 +189,7 @@ public synchronized void putResource(T newResource) { var cachedResource = getResourceFromCache(resourceId).orElse(null); if (cachedResource == null - || ReconcileUtils.compareResourceVersions(newResource, cachedResource) > 0) { + || ReconcilerUtilsInternal.compareResourceVersions(newResource, cachedResource) > 0) { log.debug( "Temporarily moving ahead to target version {} for resource id: {}", newResource.getMetadata().getResourceVersion(), 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 index 12e45b9c23..129351e8af 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsInternalTest.java @@ -17,7 +17,10 @@ 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; @@ -29,6 +32,7 @@ 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; @@ -43,7 +47,7 @@ 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"; @@ -183,4 +187,135 @@ 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/api/reconciler/ReconcileUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java index 6d8c244c83..f76ec61e16 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java @@ -20,14 +20,8 @@ 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; -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.fabric8.kubernetes.api.model.PodBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.MixedOperation; @@ -47,7 +41,6 @@ class ReconcileUtilsTest { - private static final Logger log = LoggerFactory.getLogger(ReconcileUtilsTest.class); private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; private Context context; @@ -80,113 +73,6 @@ void setupMocks() { when(mixedOperation.withName(any())).thenReturn(resourceOp); } - @Test - void validateAndCompareResourceVersionsTest() { - assertThat(ReconcileUtils.validateAndCompareResourceVersions("11", "22")).isNegative(); - assertThat(ReconcileUtils.validateAndCompareResourceVersions("22", "11")).isPositive(); - assertThat(ReconcileUtils.validateAndCompareResourceVersions("1", "1")).isZero(); - assertThat(ReconcileUtils.validateAndCompareResourceVersions("11", "11")).isZero(); - assertThat(ReconcileUtils.validateAndCompareResourceVersions("123", "2")).isPositive(); - assertThat(ReconcileUtils.validateAndCompareResourceVersions("3", "211")).isNegative(); - - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("aa", "22")); - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("11", "ba")); - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("", "22")); - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("11", "")); - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("01", "123")); - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("123", "01")); - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("3213", "123a")); - assertThrows( - NonComparableResourceVersionException.class, - () -> ReconcileUtils.validateAndCompareResourceVersions("321", "123a")); - } - - @Test - void compareResourceVersionsWithStrings() { - // Test equal versions - assertThat(ReconcileUtils.compareResourceVersions("1", "1")).isZero(); - assertThat(ReconcileUtils.compareResourceVersions("123", "123")).isZero(); - - // Test different lengths - shorter version is less than longer version - assertThat(ReconcileUtils.compareResourceVersions("1", "12")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("12", "1")).isPositive(); - assertThat(ReconcileUtils.compareResourceVersions("99", "100")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("100", "99")).isPositive(); - assertThat(ReconcileUtils.compareResourceVersions("9", "100")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("100", "9")).isPositive(); - - // Test same length - lexicographic comparison - assertThat(ReconcileUtils.compareResourceVersions("1", "2")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("2", "1")).isPositive(); - assertThat(ReconcileUtils.compareResourceVersions("11", "12")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("12", "11")).isPositive(); - assertThat(ReconcileUtils.compareResourceVersions("99", "100")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("100", "99")).isPositive(); - assertThat(ReconcileUtils.compareResourceVersions("123", "124")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("124", "123")).isPositive(); - - // Test with non-numeric strings (algorithm should still work character-wise) - assertThat(ReconcileUtils.compareResourceVersions("a", "b")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("b", "a")).isPositive(); - assertThat(ReconcileUtils.compareResourceVersions("abc", "abd")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("abd", "abc")).isPositive(); - - // Test edge cases with larger numbers - assertThat(ReconcileUtils.compareResourceVersions("1234567890", "1234567891")).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions("1234567891", "1234567890")).isPositive(); - } - - @Test - void compareResourceVersionsWithHasMetadata() { - // Test equal versions - HasMetadata resource1 = createResourceWithVersion("123"); - HasMetadata resource2 = createResourceWithVersion("123"); - assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isZero(); - - // Test different lengths - resource1 = createResourceWithVersion("1"); - resource2 = createResourceWithVersion("12"); - assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions(resource2, resource1)).isPositive(); - - // Test same length, different values - resource1 = createResourceWithVersion("100"); - resource2 = createResourceWithVersion("200"); - assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions(resource2, resource1)).isPositive(); - - // Test realistic Kubernetes resource versions - resource1 = createResourceWithVersion("12345"); - resource2 = createResourceWithVersion("12346"); - assertThat(ReconcileUtils.compareResourceVersions(resource1, resource2)).isNegative(); - assertThat(ReconcileUtils.compareResourceVersions(resource2, resource1)).isPositive(); - } - - private HasMetadata createResourceWithVersion(String resourceVersion) { - return new PodBuilder() - .withMetadata( - new ObjectMetaBuilder() - .withName("test-pod") - .withNamespace("default") - .withResourceVersion(resourceVersion) - .build()) - .build(); - } - @Test void addsFinalizer() { var resource = TestUtils.testCustomResource1(); @@ -439,26 +325,4 @@ void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); } - - // 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 = ReconcileUtils.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); - } } From a28d7b480f25bb2d76d58a64c6faf904d2caf9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 2 Feb 2026 09:36:49 +0100 Subject: [PATCH 15/58] feat: move ReconcileUtils methods to ResourceOperations accessible from Context (#3142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros Signed-off-by: Chris Laprun Co-authored-by: Chris Laprun --- .../operator/api/reconciler/Context.java | 2 + .../api/reconciler/DefaultContext.java | 7 + .../PrimaryUpdateAndCacheUtils.java | 4 +- ...cileUtils.java => ResourceOperations.java} | 390 ++++++++++-------- .../event/ReconciliationDispatcher.java | 50 +-- ...sTest.java => ResourceOperationsTest.java} | 26 +- .../event/ReconciliationDispatcherTest.java | 87 ++-- .../ChangeNamespaceTestReconciler.java | 2 +- ...cKubernetesResourceHandlingReconciler.java | 2 +- ...ultipleSecondaryEventSourceReconciler.java | 5 +- .../baseapi/simple/TestReconciler.java | 4 +- ...TriggerReconcilerOnAllEventReconciler.java | 5 +- .../SelectiveFinalizerHandlingReconciler.java | 5 +- .../ExternalStateReconciler.java | 3 +- .../operator/sample/WebPageReconciler.java | 12 +- 15 files changed, 339 insertions(+), 265 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/{ReconcileUtils.java => ResourceOperations.java} (51%) rename operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/{ReconcileUtilsTest.java => ResourceOperationsTest.java} (93%) 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..d390a5ad67 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -58,6 +58,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 f1aeadd52a..3c7d6319a6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -46,6 +46,7 @@ public class DefaultContext

implements Context

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

resourceOperations; public DefaultContext( RetryInfo retryInfo, @@ -61,6 +62,7 @@ public DefaultContext( this.primaryResourceFinalStateUnknown = primaryResourceFinalStateUnknown; this.defaultManagedDependentResourceContext = new DefaultManagedWorkflowAndDependentResourceContext<>(controller, primaryResource, this); + this.resourceOperations = new ResourceOperations<>(this); } @Override @@ -124,6 +126,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 diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java index 31c825e673..f74cd49ee7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java @@ -46,8 +46,8 @@ * If the update fails, it reads the primary resource from the cluster, applies the modifications * again and retries the update. * - * @deprecated Use {@link ReconcileUtils} that contains the more efficient up-to-date versions of - * the target utils. + * @deprecated Use {@link Context#resourceOperations()} that contains the more efficient up-to-date + * versions of methods. */ @Deprecated(forRemoval = true) public class PrimaryUpdateAndCacheUtils { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java similarity index 51% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index ed02c56a01..3fe3864403 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -23,8 +23,6 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.api.model.ObjectMeta; -import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.base.PatchContext; import io.fabric8.kubernetes.client.dsl.base.PatchType; @@ -35,33 +33,40 @@ import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; -public class ReconcileUtils { - - private static final Logger log = LoggerFactory.getLogger(ReconcileUtils.class); +/** + * 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 ReconcileUtils() {} + 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 contain to updated resource. Or more recent one if someone did an update - * after our update. - * - *

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

You are free to control the optimistic locking by setting the resource version in resource * metadata. In case of SSA we advise not to do updates with optimistic locking. * - * @param context of reconciler * @param resource fresh resource for server side apply * @return updated resource * @param resource type */ - public static R serverSideApply( - Context context, R resource) { + public R serverSideApply(R resource) { return resourcePatch( - context, resource, r -> context @@ -76,18 +81,22 @@ public static R serverSideApply( } /** - * Server-Side Apply the resource status subresource. Updates the resource status and caches the - * response if needed, ensuring the next reconciliation will contain the updated resource. + * Server-Side Apply the resource status subresource. + * + *

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

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

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

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

primary resource type */ - public static

P serverSideApplyPrimary(Context

context, P resource) { + public P serverSideApplyPrimary(P resource) { return resourcePatch( resource, r -> @@ -129,16 +142,20 @@ public static

P serverSideApplyPrimary(Context

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

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

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

primary resource type */ - public static

P serverSideApplyPrimaryStatus( - Context

context, P resource) { + public P serverSideApplyPrimaryStatus(P resource) { return resourcePatch( resource, r -> @@ -156,43 +173,56 @@ public static

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

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * - * @param context of reconciler * @param resource resource to update * @return updated resource * @param resource type */ - public static R update( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).update()); + public R update(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).update()); } /** - * Updates the resource status subresource with optimistic locking. Caches the response if needed. + * Updates the resource status subresource. + * + *

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

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * - * @param context of reconciler * @param resource resource to update * @return updated resource * @param resource type */ - public static R updateStatus( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).updateStatus()); + public R updateStatus(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus()); } /** - * Updates the primary resource with optimistic locking. Caches the response using the - * controller's event source. + * Updates the primary resource. + * + *

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

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * - * @param context of reconciler * @param resource primary resource to update * @return updated resource - * @param resource type */ - public static R updatePrimary( - Context context, R resource) { + public P updatePrimary(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).update(), @@ -200,16 +230,20 @@ public static R updatePrimary( } /** - * Updates the primary resource status subresource with optimistic locking. Caches the response - * using the controller's event source. + * Updates the primary resource status subresource. + * + *

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

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * - * @param context of reconciler * @param resource primary resource to update * @return updated resource - * @param resource type */ - public static R updatePrimaryStatus( - Context context, R resource) { + public P updatePrimaryStatus(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).updateStatus(), @@ -220,46 +254,60 @@ public static R updatePrimaryStatus( * Applies a JSON Patch to the resource. The unaryOperator function is used to modify the * resource, and the differences are sent as a JSON Patch to the Kubernetes API server. * - * @param context of reconciler + *

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 static R jsonPatch( - Context context, R resource, UnaryOperator unaryOperator) { - return resourcePatch( - context, resource, r -> context.getClient().resource(r).edit(unaryOperator)); + 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. * - * @param context of reconciler + *

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 static R jsonPatchStatus( - Context context, R resource, UnaryOperator unaryOperator) { - return resourcePatch( - context, resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); + 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. Caches the response using the controller's event - * source. + * Applies a JSON Patch to the primary resource. + * + *

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

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * - * @param context of reconciler * @param resource primary resource to patch * @param unaryOperator function to modify the resource * @return updated resource - * @param resource type */ - public static R jsonPatchPrimary( - Context context, R resource, UnaryOperator unaryOperator) { + public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { return resourcePatch( resource, r -> context.getClient().resource(r).edit(unaryOperator), @@ -267,17 +315,21 @@ public static R jsonPatchPrimary( } /** - * Applies a JSON Patch to the primary resource status subresource. Caches the response using the - * controller's event source. + * Applies a JSON Patch to the primary resource status subresource. + * + *

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

You are free to control the optimistic locking by setting the resource version in resource + * metadata. * - * @param context of reconciler * @param resource primary resource to patch * @param unaryOperator function to modify the resource * @return updated resource - * @param resource type */ - public static R jsonPatchPrimaryStatus( - Context context, R resource, UnaryOperator unaryOperator) { + public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { return resourcePatch( resource, r -> context.getClient().resource(r).editStatus(unaryOperator), @@ -288,41 +340,57 @@ public static R jsonPatchPrimaryStatus( * Applies a JSON Merge Patch to the resource. JSON Merge Patch (RFC 7386) is a simpler patching * strategy that merges the provided resource with the existing resource on the server. * - * @param context of reconciler + *

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 static R jsonMergePatch( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).patch()); + public R jsonMergePatch(R resource) { + return resourcePatch(resource, r -> context.getClient().resource(r).patch()); } /** - * Applies a JSON Merge Patch to the resource status subresource. Merges the provided resource - * status with the existing resource status on the server. + * 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 context of reconciler * @param resource resource to patch * @return updated resource * @param resource type */ - public static R jsonMergePatchStatus( - Context context, R resource) { - return resourcePatch(context, resource, r -> context.getClient().resource(r).patchStatus()); + 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. * - * @param context of reconciler + *

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

You are free to control the optimistic locking by setting the resource version in resource + * metadata. + * * @param resource primary resource to patch reconciliation * @return updated resource - * @param resource type */ - public static R jsonMergePatchPrimary( - Context context, R resource) { + public P jsonMergePatchPrimary(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).patch(), @@ -330,35 +398,40 @@ public static R jsonMergePatchPrimary( } /** - * Applies a JSON Merge Patch to the primary resource status subresource and filters out the - * resulting event. This is a convenience method that calls {@link - * #jsonMergePatchPrimaryStatus(Context, HasMetadata)} with filterEvent set to true. + * 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 context of reconciler * @param resource primary resource to patch * @return updated resource - * @param resource type - * @see #jsonMergePatchPrimaryStatus(Context, HasMetadata) + * @see #jsonMergePatchPrimaryStatus(HasMetadata) */ - public static R jsonMergePatchPrimaryStatus( - Context context, R resource) { - return jsonMergePatchPrimaryStatus(context, resource); + public P jsonMergePatchPrimaryStatus(P resource) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).patchStatus(), + context.eventSourceRetriever().getControllerEventSource()); } /** - * Internal utility method to patch a resource and cache the result. Automatically discovers the - * event source for the resource type and delegates to {@link #resourcePatch(HasMetadata, - * UnaryOperator, ManagedInformerEventSource)}. + * Utility method to patch a resource and cache the result. Automatically discovers the event + * source for the resource type and delegates to {@link #resourcePatch(HasMetadata, UnaryOperator, + * ManagedInformerEventSource)}. * - * @param context of reconciler * @param resource resource to patch * @param updateOperation operation to perform (update, patch, edit, etc.) * @return updated resource * @param resource type * @throws IllegalStateException if no event source or multiple event sources are found */ - public static R resourcePatch( - Context context, R resource, UnaryOperator updateOperation) { + @SuppressWarnings({"rawtypes", "unchecked"}) + public R resourcePatch(R resource, UnaryOperator updateOperation) { var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); if (esList.isEmpty()) { @@ -372,7 +445,7 @@ public static R resourcePatch( } var es = esList.get(0); if (es instanceof ManagedInformerEventSource mes) { - return resourcePatch(resource, updateOperation, mes); + return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes); } else { throw new IllegalStateException( "Target event source must be a subclass off " @@ -381,9 +454,9 @@ public static R resourcePatch( } /** - * Internal utility method to patch a resource and cache the result using the specified event - * source. This method either filters out the resulting event or allows it to trigger - * reconciliation based on the filterEvent parameter. + * Utility method to patch a resource and cache the result using the specified event source. This + * method either filters out the resulting event or allows it to trigger reconciliation based on + * the filterEvent parameter. * * @param resource resource to patch * @param updateOperation operation to perform (update, patch, edit, etc.) @@ -391,41 +464,38 @@ public static R resourcePatch( * @return updated resource * @param resource type */ - @SuppressWarnings("unchecked") - public static R resourcePatch( - R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { - return (R) ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); + 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(Context, String)} with the configured - * finalizer name. + * 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. * - * @param context of reconciler * @return updated resource from the server response - * @param

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

P addFinalizer(Context

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

P addFinalizer(Context

context, String finalizerName) { + public P addFinalizer(String finalizerName) { var resource = context.getPrimaryResource(); if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) { return resource; } - return conflictRetryingPatch( - context, + return conflictRetryingPatchPrimary( r -> { r.addFinalizer(finalizerName); return r; @@ -435,34 +505,31 @@ public static

P addFinalizer(Context

context, String /** * Removes the default finalizer (from controller configuration) from the primary resource. This - * is a convenience method that calls {@link #removeFinalizer(Context, String)} with the - * configured finalizer name. + * 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. * - * @param context of reconciler * @return updated resource from the server response - * @param

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

P removeFinalizer(Context

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

P removeFinalizer( - Context

context, String finalizerName) { + public P removeFinalizer(String finalizerName) { var resource = context.getPrimaryResource(); if (!resource.hasFinalizer(finalizerName)) { return resource; } - return conflictRetryingPatch( - context, + return conflictRetryingPatchPrimary( r -> { r.removeFinalizer(finalizerName); return r; @@ -479,18 +546,16 @@ public static

P removeFinalizer( /** * Patches the resource using JSON Patch. In case the server responds with conflict (HTTP 409) or * unprocessable content (HTTP 422) it retries the operation up to the maximum number defined in - * {@link ReconcileUtils#DEFAULT_MAX_RETRY}. + * {@link ResourceOperations#DEFAULT_MAX_RETRY}. * - * @param context reconciliation context * @param resourceChangesOperator changes to be done on the resource before update * @param preCondition condition to check if the patch operation still needs to be performed or * not. * @return updated resource from the server or unchanged if the precondition does not hold. - * @param

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

P conflictRetryingPatch( - Context

context, UnaryOperator

resourceChangesOperator, Predicate

preCondition) { + public P conflictRetryingPatchPrimary( + UnaryOperator

resourceChangesOperator, Predicate

preCondition) { var resource = context.getPrimaryResource(); var client = context.getClient(); if (log.isDebugEnabled()) { @@ -502,7 +567,7 @@ public static

P conflictRetryingPatch( if (!preCondition.test(resource)) { return resource; } - return jsonPatchPrimary(context, resource, resourceChangesOperator); + return jsonPatchPrimary(resource, resourceChangesOperator); } catch (KubernetesClientException e) { log.trace("Exception during patch for resource: {}", resource); retryIndex++; @@ -544,30 +609,27 @@ public static

P conflictRetryingPatch( /** * Adds the default finalizer (from controller configuration) to the primary resource using - * Server-Side Apply. This is a convenience method that calls {@link #addFinalizerWithSSA(Context, - * String)} with the configured finalizer name. + * 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. * - * @param context of reconciler * @return the patched resource from the server response - * @param

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

P addFinalizerWithSSA(Context

context) { - return addFinalizerWithSSA(context, context.getControllerConfiguration().getFinalizerName()); + 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. + * locking for the patch. Note that explicitly adding finalizer is required only if "Trigger + * reconciliation on all event" mode is on. * - * @param context of reconciler * @param finalizerName name of the finalizer to add * @return the patched resource from the server response - * @param

primary resource type */ - public static

P addFinalizerWithSSA( - Context

context, String finalizerName) { + public P addFinalizerWithSSA(String finalizerName) { var originalResource = context.getPrimaryResource(); if (log.isDebugEnabled()) { log.debug( @@ -576,14 +638,12 @@ public static

P addFinalizerWithSSA( getVersion(originalResource)); } try { + @SuppressWarnings("unchecked") P resource = (P) originalResource.getClass().getConstructor().newInstance(); - ObjectMeta objectMeta = new ObjectMeta(); - objectMeta.setName(originalResource.getMetadata().getName()); - objectMeta.setNamespace(originalResource.getMetadata().getNamespace()); - resource.setMetadata(objectMeta); + resource.initNameAndNamespaceFrom(originalResource); resource.addFinalizer(finalizerName); - return serverSideApplyPrimary(context, resource); + return serverSideApplyPrimary(resource); } catch (InstantiationException | IllegalAccessException | InvocationTargetException diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java index 82d9a3ed21..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 @@ -31,7 +31,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.Controller; @@ -73,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); @@ -98,13 +98,16 @@ && 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) { @@ -137,9 +140,9 @@ private PostExecutionControl

handleReconcile( */ P updatedResource; if (useSSA) { - updatedResource = ReconcileUtils.addFinalizerWithSSA(context); + updatedResource = context.resourceOperations().addFinalizerWithSSA(); } else { - updatedResource = ReconcileUtils.addFinalizer(context); + updatedResource = context.resourceOperations().addFinalizer(); } return PostExecutionControl.onlyFinalizerAdded(updatedResource) .withReSchedule(BaseControl.INSTANT_RESCHEDULE); @@ -321,7 +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 = ReconcileUtils.removeFinalizer(context); + P customResource = context.resourceOperations().removeFinalizer(); return PostExecutionControl.customResourceFinalizerRemoved(customResource); } } @@ -387,9 +390,9 @@ public R patchResource(Context context, R resource, R originalResource) { resource.getMetadata().getResourceVersion()); } if (useSSA) { - return ReconcileUtils.serverSideApplyPrimary(context, resource); + return context.resourceOperations().serverSideApplyPrimary(resource); } else { - return ReconcileUtils.jsonPatchPrimary(context, originalResource, r -> resource); + return context.resourceOperations().jsonPatchPrimary(originalResource, r -> resource); } } @@ -399,7 +402,7 @@ public R patchStatus(Context context, R resource, R originalResource) { var managedFields = resource.getMetadata().getManagedFields(); try { resource.getMetadata().setManagedFields(null); - return ReconcileUtils.serverSideApplyPrimaryStatus(context, resource); + return context.resourceOperations().serverSideApplyPrimaryStatus(resource); } finally { resource.getMetadata().setManagedFields(managedFields); } @@ -416,13 +419,14 @@ private R editStatus(Context context, R resource, R originalResource) { try { clonedOriginal.getMetadata().setResourceVersion(null); resource.getMetadata().setResourceVersion(null); - return ReconcileUtils.jsonPatchPrimaryStatus( - context, - clonedOriginal, - r -> { - ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); - return r; - }); + return context + .resourceOperations() + .jsonPatchPrimaryStatus( + clonedOriginal, + r -> { + ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); + return r; + }); } finally { // restore initial resource version clonedOriginal.getMetadata().setResourceVersion(resourceVersion); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java similarity index 93% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java rename to operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index f76ec61e16..82ecf8996c 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtilsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -36,10 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -class ReconcileUtilsTest { +class ResourceOperationsTest { private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; @@ -49,6 +48,7 @@ class ReconcileUtilsTest { private Resource resourceOp; private ControllerEventSource controllerEventSource; private ControllerConfiguration controllerConfiguration; + private ResourceOperations resourceOperations; @BeforeEach @SuppressWarnings("unchecked") @@ -71,6 +71,8 @@ void setupMocks() { when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); when(mixedOperation.inNamespace(any())).thenReturn(mixedOperation); when(mixedOperation.withName(any())).thenReturn(resourceOp); + + resourceOperations = new ResourceOperations<>(context); } @Test @@ -91,7 +93,7 @@ void addsFinalizer() { return res; }); - var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); + var result = resourceOperations.addFinalizer(FINALIZER_NAME); assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); @@ -118,7 +120,7 @@ void addsFinalizerWithSSA() { return res; }); - var result = ReconcileUtils.addFinalizerWithSSA(context, FINALIZER_NAME); + var result = resourceOperations.addFinalizerWithSSA(FINALIZER_NAME); assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); @@ -146,7 +148,7 @@ void removesFinalizer() { return res; }); - var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + var result = resourceOperations.removeFinalizer(FINALIZER_NAME); assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); @@ -177,7 +179,7 @@ void retriesAddingFinalizerWithoutSSA() { // Return fresh resource on retry when(resourceOp.get()).thenReturn(resource); - var result = ReconcileUtils.addFinalizer(context, FINALIZER_NAME); + var result = resourceOperations.addFinalizer(FINALIZER_NAME); assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); @@ -202,7 +204,7 @@ void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { // Return null on retry (resource was deleted) when(resourceOp.get()).thenReturn(null); - ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + resourceOperations.removeFinalizer(FINALIZER_NAME); verify(controllerEventSource, times(1)) .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); @@ -235,7 +237,7 @@ void retriesFinalizerRemovalWithFreshResource() { freshResource.addFinalizer(FINALIZER_NAME); when(resourceOp.get()).thenReturn(freshResource); - var result = ReconcileUtils.removeFinalizer(context, FINALIZER_NAME); + var result = resourceOperations.removeFinalizer(FINALIZER_NAME); assertThat(result).isNotNull(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); @@ -262,7 +264,7 @@ void resourcePatchWithSingleEventSource() { when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) .thenReturn(updatedResource); - var result = ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity()); + var result = resourceOperations.resourcePatch(resource, UnaryOperator.identity()); assertThat(result).isNotNull(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); @@ -282,7 +284,7 @@ void resourcePatchThrowsWhenNoEventSourceFound() { var exception = assertThrows( IllegalStateException.class, - () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); assertThat(exception.getMessage()).contains("No event source found for type"); } @@ -301,7 +303,7 @@ void resourcePatchThrowsWhenMultipleEventSourcesFound() { var exception = assertThrows( IllegalStateException.class, - () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); assertThat(exception.getMessage()).contains("Multiple event sources found for"); assertThat(exception.getMessage()).contains("please provide the target event source"); @@ -320,7 +322,7 @@ void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { var exception = assertThrows( IllegalStateException.class, - () -> ReconcileUtils.resourcePatch(context, resource, UnaryOperator.identity())); + () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java index 13673a72d5..c7d9458695 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java @@ -26,7 +26,6 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; -import org.mockito.MockedStatic; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.ObjectMeta; @@ -45,8 +44,8 @@ import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.Controller; @@ -72,6 +71,7 @@ class ReconciliationDispatcherTest { private final CustomResourceFacade customResourceFacade = mock(ReconciliationDispatcher.CustomResourceFacade.class); private static ConfigurationService configurationService; + private ResourceOperations mockResourceOperations; @BeforeEach void setup() { @@ -151,27 +151,25 @@ public boolean useFinalizer() { } @Test - void addFinalizerOnNewResource() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizerWithSSA(any()), times(1)); - } + void addFinalizerOnNewResource() throws Exception { + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), createTestContext()); + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(mockResourceOperations, times(1)).addFinalizerWithSSA(); } @Test - void addFinalizerOnNewResourceWithoutSSA() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - initConfigService(false, false); - final ReconciliationDispatcher dispatcher = - init(testCustomResource, reconciler, null, customResourceFacade, true); + void addFinalizerOnNewResourceWithoutSSA() throws Exception { + initConfigService(false, false); + final ReconciliationDispatcher dispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, true); + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); - dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizer(any()), times(1)); - } + dispatcher.handleDispatch(executionScopeWithCREvent(testCustomResource), createTestContext()); + + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(mockResourceOperations, times(1)).addFinalizer(); } @Test @@ -227,17 +225,16 @@ void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() { } @Test - void removesDefaultFinalizerOnDeleteIfSet() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); + void removesDefaultFinalizerOnDeleteIfSet() throws Exception { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + markForDeletion(testCustomResource); - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + var postExecControl = + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), createTestContext()); - assertThat(postExecControl.isFinalizerRemoved()).isTrue(); - mockedReconcileUtils.verify(() -> ReconcileUtils.removeFinalizer(any()), times(1)); - } + assertThat(postExecControl.isFinalizerRemoved()).isTrue(); + verify(mockResourceOperations, times(1)).removeFinalizer(); } @Test @@ -295,20 +292,21 @@ void doesNotUpdateTheResourceIfNoUpdateUpdateControlIfFinalizerSet() { } @Test - void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() { - try (MockedStatic mockedReconcileUtils = mockStatic(ReconcileUtils.class)) { - removeFinalizers(testCustomResource); - reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); - mockedReconcileUtils - .when(() -> ReconcileUtils.addFinalizerWithSSA(any())) - .thenReturn(testCustomResource); - var postExecControl = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() throws Exception { - mockedReconcileUtils.verify(() -> ReconcileUtils.addFinalizerWithSSA(any()), times(1)); - assertThat(postExecControl.updateIsStatusPatch()).isFalse(); - assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); - } + removeFinalizers(testCustomResource); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + var context = createTestContext(); + when(mockResourceOperations.addFinalizerWithSSA()).thenReturn(testCustomResource); + + var postExecControl = + reconciliationDispatcher.handleDispatch( + executionScopeWithCREvent(testCustomResource), context); + + verify(mockResourceOperations, times(1)).addFinalizerWithSSA(); + + assertThat(postExecControl.updateIsStatusPatch()).isFalse(); + assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); } @Test @@ -646,6 +644,13 @@ void reconcilerContextUsesTheSameInstanceOfResourceAsParam() { .isNotSameAs(testCustomResource); } + private Context createTestContext() { + var mockContext = mock(Context.class); + mockResourceOperations = mock(ResourceOperations.class); + when(mockContext.resourceOperations()).thenReturn(mockResourceOperations); + return mockContext; + } + private ObservedGenCustomResource createObservedGenCustomResource() { ObservedGenCustomResource observedGenCustomResource = new ObservedGenCustomResource(); observedGenCustomResource.setMetadata(new ObjectMeta()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index 96bd43c9e2..d05364fc44 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -53,7 +53,7 @@ public UpdateControl reconcile( ChangeNamespaceTestCustomResource primary, Context context) { - ReconcileUtils.serverSideApply(context, configMap(primary)); + context.resourceOperations().serverSideApply(configMap(primary)); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java index f76443c103..7efa8a0ad6 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java @@ -40,7 +40,7 @@ public UpdateControl reconcile( GenericKubernetesResourceHandlingCustomResource primary, Context context) { - ReconcileUtils.serverSideApply(context, desiredConfigMap(primary, context)); + context.resourceOperations().serverSideApply(desiredConfigMap(primary, context)); return UpdateControl.noUpdate(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java index aea2dfe0c2..2a11be1faf 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java @@ -26,7 +26,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -54,8 +53,8 @@ public UpdateControl reconcile( Context context) { numberOfExecutions.addAndGet(1); - ReconcileUtils.serverSideApply(context, configMap(getName1(resource), resource)); - ReconcileUtils.serverSideApply(context, configMap(getName2(resource), resource)); + context.resourceOperations().serverSideApply(configMap(getName1(resource), resource)); + context.resourceOperations().serverSideApply(configMap(getName2(resource), resource)); if (numberOfExecutions.get() >= 3) { if (context.getSecondaryResources(ConfigMap.class).size() != 2) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java index 49dbe80554..974427ba43 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java @@ -69,7 +69,7 @@ public UpdateControl reconcile( if (existingConfigMap != null) { existingConfigMap.setData(configMapData(resource)); log.info("Updating config map"); - ReconcileUtils.serverSideApply(context, existingConfigMap); + context.resourceOperations().serverSideApply(existingConfigMap); } else { Map labels = new HashMap<>(); labels.put("managedBy", TestReconciler.class.getSimpleName()); @@ -84,7 +84,7 @@ public UpdateControl reconcile( .withData(configMapData(resource)) .build(); log.info("Creating config map"); - ReconcileUtils.serverSideApply(context, newConfigMap); + context.resourceOperations().serverSideApply(newConfigMap); } if (updateStatus) { var statusUpdateResource = new TestCustomResource(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java index 2217662402..f8804bd25d 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java @@ -22,7 +22,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -75,7 +74,7 @@ public UpdateControl reconcile( if (!primary.isMarkedForDeletion() && getUseFinalizer() && !primary.hasFinalizer(FINALIZER)) { log.info("Adding finalizer"); - ReconcileUtils.addFinalizer(context, FINALIZER); + context.resourceOperations().addFinalizer(FINALIZER); return UpdateControl.noUpdate(); } @@ -98,7 +97,7 @@ public UpdateControl reconcile( setEventOnMarkedForDeletion(true); if (getUseFinalizer() && primary.hasFinalizer(FINALIZER)) { log.info("Removing finalizer"); - ReconcileUtils.removeFinalizer(context, FINALIZER); + context.resourceOperations().removeFinalizer(FINALIZER); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java index f9198d0eae..a7bf76a6e7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling/SelectiveFinalizerHandlingReconciler.java @@ -17,7 +17,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -37,11 +36,11 @@ public UpdateControl reconci } if (resource.getSpec().getUseFinalizer()) { - ReconcileUtils.addFinalizer(context, FINALIZER); + context.resourceOperations().addFinalizer(FINALIZER); } if (resource.isMarkedForDeletion()) { - ReconcileUtils.removeFinalizer(context, FINALIZER); + context.resourceOperations().removeFinalizer(FINALIZER); } return UpdateControl.noUpdate(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java index b97d8ef679..4f4cab80d7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java @@ -32,7 +32,6 @@ import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; -import io.javaoperatorsdk.operator.api.reconciler.ReconcileUtils; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -111,7 +110,7 @@ private void createExternalResource( // This is critical in this case, since on next reconciliation if it would not be in the cache // it would be created again. configMapEventSource.eventFilteringUpdateAndCacheResource( - configMap, toCreate -> ReconcileUtils.serverSideApply(context, toCreate)); + configMap, toCreate -> context.resourceOperations().serverSideApply(toCreate)); externalResourceEventSource.handleRecentResourceCreate(primaryID, createdResource); } diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index 13fede9fcc..f46ccb193e 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -105,7 +105,7 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating ConfigMap {} in {}", desiredHtmlConfigMap.getMetadata().getName(), ns); - ReconcileUtils.serverSideApply(context, desiredHtmlConfigMap); + context.resourceOperations().serverSideApply(desiredHtmlConfigMap); } var existingDeployment = context.getSecondaryResource(Deployment.class).orElse(null); @@ -114,23 +114,21 @@ public UpdateControl reconcile(WebPage webPage, Context contex "Creating or updating Deployment {} in {}", desiredDeployment.getMetadata().getName(), ns); - ReconcileUtils.serverSideApply(context, desiredDeployment); + context.resourceOperations().serverSideApply(desiredDeployment); } var existingService = context.getSecondaryResource(Service.class).orElse(null); if (!match(desiredService, existingService)) { log.info( - "Creating or updating Deployment {} in {}", - desiredDeployment.getMetadata().getName(), - ns); - ReconcileUtils.serverSideApply(context, desiredService); + "Creating or updating Service {} in {}", desiredDeployment.getMetadata().getName(), ns); + context.resourceOperations().serverSideApply(desiredService); } var existingIngress = context.getSecondaryResource(Ingress.class); if (Boolean.TRUE.equals(webPage.getSpec().getExposed())) { var desiredIngress = makeDesiredIngress(webPage); if (existingIngress.isEmpty() || !match(desiredIngress, existingIngress.get())) { - ReconcileUtils.serverSideApply(context, desiredIngress); + context.resourceOperations().serverSideApply(desiredDeployment); } } else existingIngress.ifPresent(ingress -> context.getClient().resource(ingress).delete()); From 59da8243de390de406afdf497161d326d51bc691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 2 Feb 2026 14:52:02 +0100 Subject: [PATCH 16/58] improve: KubernetesDependentResource uses resource operations directly (#3146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 98 +++++++++++++++++++ .../KubernetesDependentResource.java | 69 +++++-------- 2 files changed, 120 insertions(+), 47 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 3fe3864403..9c42e6adfb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -28,6 +28,7 @@ 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; @@ -80,6 +81,40 @@ public R serverSideApply(R resource) { .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. * @@ -189,6 +224,69 @@ 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. * 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 b9ea27b190..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; @@ -71,28 +70,10 @@ public void configureWith(KubernetesDependentResourceConfig config) { this.kubernetesDependentResourceConfig = config; } - @Override - protected R handleCreate(R desired, P primary, Context

context) { - return eventSource() - .orElseThrow() - .eventFilteringUpdateAndCacheResource( - desired, - toCreate -> KubernetesDependentResource.super.handleCreate(toCreate, primary, context)); - } - - @Override - protected R handleUpdate(R actual, R desired, P primary, Context

context) { - return eventSource() - .orElseThrow() - .eventFilteringUpdateAndCacheResource( - desired, - toUpdate -> - KubernetesDependentResource.super.handleUpdate(actual, toUpdate, primary, context)); - } - @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 @@ -104,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()); @@ -203,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); From 9faeff4afe373ca4167d5a6a429060bffc4e92c9 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Tue, 3 Feb 2026 12:09:24 +0100 Subject: [PATCH 17/58] feat: provide de-duplicated secondary resources stream on Context (#3141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Chris Laprun Co-authored-by: Attila Mészáros --- .../operator/api/reconciler/Context.java | 75 +++++++++- .../api/reconciler/DefaultContext.java | 44 +++++- .../api/reconciler/ResourceOperations.java | 10 +- .../operator/processing/event/ResourceID.java | 23 ++- .../source/informer/InformerEventSource.java | 3 +- .../api/reconciler/DefaultContextTest.java | 125 +++++++++++++++- .../reconciler/ResourceOperationsTest.java | 25 ++-- .../latestdistinct/LatestDistinctIT.java | 125 ++++++++++++++++ .../LatestDistinctTestReconciler.java | 140 ++++++++++++++++++ .../LatestDistinctTestResource.java | 40 +++++ .../LatestDistinctTestResourceSpec.java | 28 ++++ .../LatestDistinctTestResourceStatus.java | 28 ++++ 12 files changed, 628 insertions(+), 38 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java index d390a5ad67..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(); 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 3c7d6319a6..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,6 +15,7 @@ */ package io.javaoperatorsdk.operator.api.reconciler; +import java.util.HashSet; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -26,6 +27,7 @@ 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; @@ -36,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; @@ -71,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 diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 9c42e6adfb..de4d00d717 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -535,13 +535,13 @@ public R resourcePatch(R resource, UnaryOperator upda if (esList.isEmpty()) { throw new IllegalStateException("No event source found for type: " + resource.getClass()); } + var es = esList.get(0); if (esList.size() > 1) { - throw new IllegalStateException( - "Multiple event sources found for: " - + resource.getClass() - + " please provide the target event source"); + log.warn( + "Multiple event sources found for type: {}, selecting first with name {}", + resource.getClass(), + es.name()); } - var es = esList.get(0); if (es instanceof ManagedInformerEventSource mes) { return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes); } else { 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/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 6743ff436a..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 @@ -218,7 +218,8 @@ public Set getSecondaryResources(P primary) { } return secondaryIDs.stream() .map(this::get) - .flatMap(Optional::stream) + .filter(Optional::isPresent) + .map(Optional::get) .collect(Collectors.toSet()); } 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/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 82ecf8996c..8d0176cd4a 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -38,27 +38,27 @@ 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; - private KubernetesClient client; - private MixedOperation mixedOperation; + + @SuppressWarnings("rawtypes") private Resource resourceOp; + private ControllerEventSource controllerEventSource; - private ControllerConfiguration controllerConfiguration; private ResourceOperations resourceOperations; @BeforeEach - @SuppressWarnings("unchecked") void setupMocks() { context = mock(Context.class); - client = mock(KubernetesClient.class); - mixedOperation = mock(MixedOperation.class); + final var client = mock(KubernetesClient.class); + final var mixedOperation = mock(MixedOperation.class); resourceOp = mock(Resource.class); controllerEventSource = mock(ControllerEventSource.class); - controllerConfiguration = mock(ControllerConfiguration.class); + final var controllerConfiguration = mock(ControllerConfiguration.class); var eventSourceRetriever = mock(EventSourceRetriever.class); @@ -290,7 +290,7 @@ void resourcePatchThrowsWhenNoEventSourceFound() { } @Test - void resourcePatchThrowsWhenMultipleEventSourcesFound() { + void resourcePatchUsesFirstEventSourceIfMultipleEventSourcesPresent() { var resource = TestUtils.testCustomResource1(); var eventSourceRetriever = mock(EventSourceRetriever.class); var eventSource1 = mock(ManagedInformerEventSource.class); @@ -300,13 +300,10 @@ void resourcePatchThrowsWhenMultipleEventSourcesFound() { when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) .thenReturn(List.of(eventSource1, eventSource2)); - var exception = - assertThrows( - IllegalStateException.class, - () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); + resourceOperations.resourcePatch(resource, UnaryOperator.identity()); - assertThat(exception.getMessage()).contains("Multiple event sources found for"); - assertThat(exception.getMessage()).contains("please provide the target event source"); + verify(eventSource1, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java new file mode 100644 index 0000000000..24cee17f04 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java @@ -0,0 +1,125 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.latestdistinct.LatestDistinctTestReconciler.LABEL_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Latest Distinct with Multiple InformerEventSources", + description = + """ + Demonstrates using two separate InformerEventSource instances for ConfigMaps with \ + overlapping watches, combined with latestDistinctList() to deduplicate resources by \ + keeping the latest version. Also tests ReconcileUtils methods for patching resources \ + with proper cache updates. + """) +class LatestDistinctIT { + + public static final String TEST_RESOURCE_NAME = "test-resource"; + public static final String CONFIG_MAP_1 = "config-map-1"; + public static final String DEFAULT_VALUE = "defaultValue"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(LatestDistinctTestReconciler.class) + .build(); + + @Test + void testLatestDistinctListWithTwoInformerEventSources() { + // Create the custom resource + var resource = createTestCustomResource(); + resource = extension.create(resource); + + // Create ConfigMaps with type1 label (watched by first event source) + var cm1 = createConfigMap(CONFIG_MAP_1, resource); + extension.create(cm1); + + // Wait for reconciliation + var reconciler = extension.getReconcilerOfType(LatestDistinctTestReconciler.class); + await() + .atMost(Duration.ofSeconds(5)) + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var updatedResource = + extension.get(LatestDistinctTestResource.class, TEST_RESOURCE_NAME); + assertThat(updatedResource.getStatus()).isNotNull(); + // Should see 1 distinct ConfigMaps + assertThat(updatedResource.getStatus().getConfigMapCount()).isEqualTo(1); + assertThat(reconciler.isErrorOccurred()).isFalse(); + // note that since there are two event source, and we do the update through one event + // source + // the other will still propagate an event + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(2); + }); + } + + private LatestDistinctTestResource createTestCustomResource() { + var resource = new LatestDistinctTestResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(extension.getNamespace()) + .build()); + resource.setSpec(new LatestDistinctTestResourceSpec()); + return resource; + } + + private ConfigMap createConfigMap(String name, LatestDistinctTestResource owner) { + Map labels = new HashMap<>(); + labels.put(LABEL_KEY, "val"); + + Map data = new HashMap<>(); + data.put("key", DEFAULT_VALUE); + + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(name) + .withNamespace(extension.getNamespace()) + .withLabels(labels) + .build()) + .withData(data) + .withNewMetadata() + .withName(name) + .withNamespace(extension.getNamespace()) + .withLabels(labels) + .addNewOwnerReference() + .withApiVersion(owner.getApiVersion()) + .withKind(owner.getKind()) + .withName(owner.getMetadata().getName()) + .withUid(owner.getMetadata().getUid()) + .endOwnerReference() + .endMetadata() + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java new file mode 100644 index 0000000000..77745ddaba --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java @@ -0,0 +1,140 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class LatestDistinctTestReconciler implements Reconciler { + + public static final String EVENT_SOURCE_1_NAME = "configmap-es-1"; + public static final String EVENT_SOURCE_2_NAME = "configmap-es-2"; + public static final String LABEL_KEY = "configmap-type"; + public static final String KEY_2 = "key2"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private volatile boolean errorOccurred = false; + + @Override + public UpdateControl reconcile( + LatestDistinctTestResource resource, Context context) { + + // Update status with information from ConfigMaps + if (resource.getStatus() == null) { + resource.setStatus(new LatestDistinctTestResourceStatus()); + } + var allConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class).toList(); + if (allConfigMaps.size() < 2) { + // wait until both informers see the config map + return UpdateControl.noUpdate(); + } + // makes sure that distinc config maps returned + var distinctConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class, true).toList(); + if (distinctConfigMaps.size() != 1) { + errorOccurred = true; + throw new IllegalStateException(); + } + + resource.getStatus().setConfigMapCount(distinctConfigMaps.size()); + var configMap = distinctConfigMaps.get(0); + configMap.setData(Map.of(KEY_2, "val2")); + var updated = context.resourceOperations().update(configMap); + + // makes sure that distinct config maps returned + distinctConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class, true).toList(); + if (distinctConfigMaps.size() != 1) { + errorOccurred = true; + throw new IllegalStateException(); + } + configMap = distinctConfigMaps.get(0); + if (!configMap.getData().containsKey(KEY_2) + || !configMap + .getMetadata() + .getResourceVersion() + .equals(updated.getMetadata().getResourceVersion())) { + errorOccurred = true; + throw new IllegalStateException(); + } + numberOfExecutions.incrementAndGet(); + return UpdateControl.patchStatus(resource); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var configEs1 = + InformerEventSourceConfiguration.from(ConfigMap.class, LatestDistinctTestResource.class) + .withName(EVENT_SOURCE_1_NAME) + .withLabelSelector(LABEL_KEY) + .withNamespacesInheritedFromController() + .withSecondaryToPrimaryMapper( + cm -> + Set.of( + new ResourceID( + cm.getMetadata().getOwnerReferences().get(0).getName(), + cm.getMetadata().getNamespace()))) + .build(); + + var configEs2 = + InformerEventSourceConfiguration.from(ConfigMap.class, LatestDistinctTestResource.class) + .withName(EVENT_SOURCE_2_NAME) + .withLabelSelector(LABEL_KEY) + .withNamespacesInheritedFromController() + .withSecondaryToPrimaryMapper( + cm -> + Set.of( + new ResourceID( + cm.getMetadata().getOwnerReferences().get(0).getName(), + cm.getMetadata().getNamespace()))) + .build(); + + return List.of( + new InformerEventSource<>(configEs1, context), + new InformerEventSource<>(configEs2, context)); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + LatestDistinctTestResource resource, + Context context, + Exception e) { + errorOccurred = true; + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + public boolean isErrorOccurred() { + return errorOccurred; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java new file mode 100644 index 0000000000..546e349b0a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java @@ -0,0 +1,40 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("ldt") +public class LatestDistinctTestResource + extends CustomResource + implements Namespaced { + + @Override + protected LatestDistinctTestResourceSpec initSpec() { + return new LatestDistinctTestResourceSpec(); + } + + @Override + protected LatestDistinctTestResourceStatus initStatus() { + return new LatestDistinctTestResourceStatus(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java new file mode 100644 index 0000000000..acfefab85e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java @@ -0,0 +1,28 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +public class LatestDistinctTestResourceSpec { + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java new file mode 100644 index 0000000000..fd5ff82df5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java @@ -0,0 +1,28 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +public class LatestDistinctTestResourceStatus { + private int configMapCount; + + public int getConfigMapCount() { + return configMapCount; + } + + public void setConfigMapCount(int configMapCount) { + this.configMapCount = configMapCount; + } +} From bf394a178cfad28c82c81bbb7e5e06f2eb1a4cfe Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 5 Feb 2026 16:20:48 +0100 Subject: [PATCH 18/58] refactor: avoid creating intermediate collections when unneeded (#3156) * refactor: avoid creating intermediate collections when unneeded Also use constant filters and collectors Signed-off-by: Chris Laprun * fix: incorrect test Signed-off-by: Chris Laprun --------- Signed-off-by: Chris Laprun --- .../javaoperatorsdk/operator/RuntimeInfo.java | 4 +- .../operator/health/ControllerHealthInfo.java | 49 +++++++++++++------ .../health/InformerHealthIndicator.java | 3 -- .../processing/event/EventSourceManager.java | 7 ++- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java index 1a51c45b70..ba874bdc07 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java @@ -63,9 +63,7 @@ private void checkIfStarted() { public boolean allEventSourcesAreHealthy() { checkIfStarted(); return registeredControllers.stream() - .filter(rc -> !rc.getControllerHealthInfo().unhealthyEventSources().isEmpty()) - .findFirst() - .isEmpty(); + .noneMatch(rc -> rc.getControllerHealthInfo().hasUnhealthyEventSources()); } /** diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java index 4a78e60f05..f2a9359e04 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java @@ -16,7 +16,10 @@ package io.javaoperatorsdk.operator.health; import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collector; import java.util.stream.Collectors; +import java.util.stream.Stream; import io.javaoperatorsdk.operator.processing.event.EventSourceManager; import io.javaoperatorsdk.operator.processing.event.source.EventSource; @@ -25,6 +28,17 @@ @SuppressWarnings("rawtypes") public class ControllerHealthInfo { + private static final Predicate UNHEALTHY = e -> e.getStatus() == Status.UNHEALTHY; + private static final Predicate INFORMER = + e -> e instanceof InformerWrappingEventSourceHealthIndicator; + private static final Predicate UNHEALTHY_INFORMER = + e -> INFORMER.test(e) && e.getStatus() == Status.UNHEALTHY; + private static final Collector> + NAME_TO_ES_MAP = Collectors.toMap(EventSource::name, e -> e); + private static final Collector< + EventSource, ?, Map> + NAME_TO_ES_HEALTH_MAP = + Collectors.toMap(EventSource::name, e -> (InformerWrappingEventSourceHealthIndicator) e); private final EventSourceManager eventSourceManager; public ControllerHealthInfo(EventSourceManager eventSourceManager) { @@ -32,23 +46,31 @@ public ControllerHealthInfo(EventSourceManager eventSourceManager) { } public Map eventSourceHealthIndicators() { - return eventSourceManager.allEventSources().stream() - .collect(Collectors.toMap(EventSource::name, e -> e)); + return eventSourceManager.allEventSourcesStream().collect(NAME_TO_ES_MAP); + } + + /** + * Whether the associated {@link io.javaoperatorsdk.operator.processing.Controller} has unhealthy + * event sources. + * + * @return {@code true} if any of the associated controller is unhealthy, {@code false} otherwise + * @since 5.3.0 + */ + public boolean hasUnhealthyEventSources() { + return filteredEventSources(UNHEALTHY).findAny().isPresent(); } public Map unhealthyEventSources() { - return eventSourceManager.allEventSources().stream() - .filter(e -> e.getStatus() == Status.UNHEALTHY) - .collect(Collectors.toMap(EventSource::name, e -> e)); + return filteredEventSources(UNHEALTHY).collect(NAME_TO_ES_MAP); + } + + private Stream filteredEventSources(Predicate filter) { + return eventSourceManager.allEventSourcesStream().filter(filter); } public Map informerEventSourceHealthIndicators() { - return eventSourceManager.allEventSources().stream() - .filter(e -> e instanceof InformerWrappingEventSourceHealthIndicator) - .collect( - Collectors.toMap( - EventSource::name, e -> (InformerWrappingEventSourceHealthIndicator) e)); + return filteredEventSources(INFORMER).collect(NAME_TO_ES_HEALTH_MAP); } /** @@ -58,11 +80,6 @@ public Map unhealthyEventSources() { */ public Map unhealthyInformerEventSourceHealthIndicators() { - return eventSourceManager.allEventSources().stream() - .filter(e -> e.getStatus() == Status.UNHEALTHY) - .filter(e -> e instanceof InformerWrappingEventSourceHealthIndicator) - .collect( - Collectors.toMap( - EventSource::name, e -> (InformerWrappingEventSourceHealthIndicator) e)); + return filteredEventSources(UNHEALTHY_INFORMER).collect(NAME_TO_ES_HEALTH_MAP); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java index 66d24aa383..6c39a2601b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java @@ -23,8 +23,5 @@ public interface InformerHealthIndicator extends EventSourceHealthIndicator { boolean isRunning(); - @Override - Status getStatus(); - String getTargetNamespace(); } 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 62e19394c8..441d3cf178 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 @@ -217,7 +217,12 @@ public Set> getRegisteredEventSources() { @SuppressWarnings("rawtypes") public List allEventSources() { - return eventSources.allEventSources().toList(); + return allEventSourcesStream().toList(); + } + + @SuppressWarnings("rawtypes") + public Stream allEventSourcesStream() { + return eventSources.allEventSources(); } @SuppressWarnings("unused") From 1e09e29d8423a15c36e085ce86c2dd8327b7d62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 6 Feb 2026 14:18:44 +0100 Subject: [PATCH 19/58] improve: event filtering algorithm for multiple parallel updates (#3155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../source/informer/EventFilterDetails.java | 28 ++++++- .../informer/ManagedInformerEventSource.java | 4 +- .../informer/TemporaryResourceCache.java | 18 ++++- .../informer/InformerEventSourceTest.java | 75 ++++++++++++++++++- 4 files changed, 111 insertions(+), 14 deletions(-) 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 index 8b573a986c..b747c69dff 100644 --- 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 @@ -16,7 +16,9 @@ package io.javaoperatorsdk.operator.processing.event.source.informer; import java.util.Optional; +import java.util.function.UnaryOperator; +import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; @@ -24,12 +26,26 @@ class EventFilterDetails { private int activeUpdates = 0; private ResourceEvent lastEvent; + private String lastOwnUpdatedResourceVersion; public void increaseActiveUpdates() { activeUpdates = activeUpdates + 1; } - public boolean decreaseActiveUpdates() { + /** + * resourceVersion is needed for case when multiple parallel updates happening inside the + * controller to prevent race condition and send event from {@link + * ManagedInformerEventSource#eventFilteringUpdateAndCacheResource(HasMetadata, UnaryOperator)} + */ + public boolean decreaseActiveUpdates(String updatedResourceVersion) { + if (updatedResourceVersion != null + && (lastOwnUpdatedResourceVersion == null + || ReconcilerUtilsInternal.compareResourceVersions( + updatedResourceVersion, lastOwnUpdatedResourceVersion) + > 0)) { + lastOwnUpdatedResourceVersion = updatedResourceVersion; + } + activeUpdates = activeUpdates - 1; return activeUpdates == 0; } @@ -38,15 +54,19 @@ public void setLastEvent(ResourceEvent event) { lastEvent = event; } - public Optional getLatestEventAfterLastUpdateEvent(String updatedResourceVersion) { + public Optional getLatestEventAfterLastUpdateEvent() { if (lastEvent != null - && (updatedResourceVersion == null + && (lastOwnUpdatedResourceVersion == null || ReconcilerUtilsInternal.compareResourceVersions( lastEvent.getResource().orElseThrow().getMetadata().getResourceVersion(), - updatedResourceVersion) + lastOwnUpdatedResourceVersion) > 0)) { return Optional.of(lastEvent); } return Optional.empty(); } + + public int getActiveUpdates() { + return activeUpdates; + } } 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 dcfe687a2f..301ece4424 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 @@ -93,9 +93,7 @@ public void changeNamespaces(Set namespaces) { @SuppressWarnings("unchecked") public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { ResourceID id = ResourceID.fromResource(resourceToUpdate); - if (log.isDebugEnabled()) { - log.debug("Update and cache: {}", id); - } + log.debug("Update and cache: {}", id); R updatedResource = null; try { temporaryResourceCache.startEventFilteringModify(id); 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 6e1d30c323..1dbbf36043 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 @@ -85,12 +85,22 @@ public synchronized Optional doneEventFilterModify( return Optional.empty(); } var ed = activeUpdates.get(resourceID); - if (ed.decreaseActiveUpdates()) { - activeUpdates.remove(resourceID); - return ed.getLatestEventAfterLastUpdateEvent(updatedResourceVersion); - } else { + if (ed == null || !ed.decreaseActiveUpdates(updatedResourceVersion)) { + log.debug( + "Active updates {} for resource id: {}", + ed != null ? ed.getActiveUpdates() : 0, + resourceID); return Optional.empty(); } + activeUpdates.remove(resourceID); + var res = ed.getLatestEventAfterLastUpdateEvent(); + log.debug( + "Zero active updates for resource id: {}; event after update event: {}; updated resource" + + " version: {}", + resourceID, + res.isPresent(), + updatedResourceVersion); + return res; } public void onDeleteEvent(T resource, boolean unknownState) { 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 e2c3de8975..c3a6f8e91e 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 @@ -267,14 +267,83 @@ void filterAddEventBeforeUpdate() { assertNoEventProduced(); } + @Test + void multipleCachingFilteringUpdates() { + withRealTemporaryResourceCache(); + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + latch2.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + + assertNoEventProduced(); + } + + @Test + void multipleCachingFilteringUpdates_variant2() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch2.countDown(); + + assertNoEventProduced(); + } + + @Test + void multipleCachingFilteringUpdates_variant3() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + latch.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch2.countDown(); + + assertNoEventProduced(); + } + + @Test + void multipleCachingFilteringUpdates_variant4() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch.countDown(); + latch2.countDown(); + + assertNoEventProduced(); + } + private void assertNoEventProduced() { await() .pollDelay(Duration.ofMillis(50)) .timeout(Duration.ofMillis(51)) .untilAsserted( - () -> { - verify(informerEventSource, never()).handleEvent(any(), any(), any(), any()); - }); + () -> verify(informerEventSource, never()).handleEvent(any(), any(), any(), any())); } private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { From a33dbf7002f7819cd1d5d094a14eb9fe9fc7de07 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Fri, 6 Feb 2026 16:22:40 +0100 Subject: [PATCH 20/58] improve: prepare for removal of exitOnStopLeading from public API (#3161) * improve: prepare for removal of exitOnStopLeading from public API Signed-off-by: Chris Laprun * fix: missed deprecated annotation [skip ci] Signed-off-by: Chris Laprun --------- Signed-off-by: Chris Laprun --- .../config/LeaderElectionConfiguration.java | 40 ++++++++++--------- .../LeaderElectionConfigurationBuilder.java | 15 +++++-- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java index 1072fb823d..ca777bd2cc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java @@ -37,6 +37,10 @@ public class LeaderElectionConfiguration { private final LeaderCallbacks leaderCallbacks; private final boolean exitOnStopLeading; + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration(String leaseName, String leaseNamespace, String identity) { this( leaseName, @@ -49,30 +53,26 @@ public LeaderElectionConfiguration(String leaseName, String leaseNamespace, Stri true); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration(String leaseName, String leaseNamespace) { - this( - leaseName, - leaseNamespace, - LEASE_DURATION_DEFAULT_VALUE, - RENEW_DEADLINE_DEFAULT_VALUE, - RETRY_PERIOD_DEFAULT_VALUE, - null, - null, - true); + this(leaseName, leaseNamespace, null); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration(String leaseName) { - this( - leaseName, - null, - LEASE_DURATION_DEFAULT_VALUE, - RENEW_DEADLINE_DEFAULT_VALUE, - RETRY_PERIOD_DEFAULT_VALUE, - null, - null, - true); + this(leaseName, null); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated(forRemoval = true) public LeaderElectionConfiguration( String leaseName, String leaseNamespace, @@ -82,6 +82,10 @@ public LeaderElectionConfiguration( this(leaseName, leaseNamespace, leaseDuration, renewDeadline, retryPeriod, null, null, true); } + /** + * @deprecated Use {@link LeaderElectionConfigurationBuilder} instead + */ + @Deprecated // this will be made package-only public LeaderElectionConfiguration( String leaseName, String leaseNamespace, diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java index 74f2c81cba..51ee40d84c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java @@ -31,7 +31,6 @@ public final class LeaderElectionConfigurationBuilder { private Duration renewDeadline = RENEW_DEADLINE_DEFAULT_VALUE; private Duration retryPeriod = RETRY_PERIOD_DEFAULT_VALUE; private LeaderCallbacks leaderCallbacks; - private boolean exitOnStopLeading = true; private LeaderElectionConfigurationBuilder(String leaseName) { this.leaseName = leaseName; @@ -71,12 +70,22 @@ public LeaderElectionConfigurationBuilder withLeaderCallbacks(LeaderCallbacks le return this; } + /** + * @deprecated Use {@link #buildForTest(boolean)} instead as setting this to false should only be + * used for testing purposes + */ + @Deprecated(forRemoval = true) public LeaderElectionConfigurationBuilder withExitOnStopLeading(boolean exitOnStopLeading) { - this.exitOnStopLeading = exitOnStopLeading; - return this; + throw new UnsupportedOperationException( + "Setting exitOnStopLeading should only be used for testing purposes, use buildForTest" + + " instead"); } public LeaderElectionConfiguration build() { + return buildForTest(false); + } + + public LeaderElectionConfiguration buildForTest(boolean exitOnStopLeading) { return new LeaderElectionConfiguration( leaseName, leaseNamespace, From 4d43730c339ca5a4553987f8ef3fc8a2f25989d3 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 19 Feb 2026 13:11:34 +0100 Subject: [PATCH 21/58] fix: typo (#3173) [skip ci] Signed-off-by: Chris Laprun --- .../baseapi/latestdistinct/LatestDistinctTestReconciler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java index 77745ddaba..d53ed738db 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java @@ -56,7 +56,7 @@ public UpdateControl reconcile( // wait until both informers see the config map return UpdateControl.noUpdate(); } - // makes sure that distinc config maps returned + // makes sure that distinct config maps returned var distinctConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class, true).toList(); if (distinctConfigMaps.size() != 1) { errorOccurred = true; From 76614e1b3f28d85e83c894c21abb97dfd89ad3a4 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 19 Feb 2026 14:12:46 +0100 Subject: [PATCH 22/58] fix: incorrect logic by introducing createOrUpdate method (#3172) --- .../operator/sample/WebPageReconciler.java | 76 +++++++++---------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index f46ccb193e..eba68d9381 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -19,8 +19,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.BiFunction; -import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -90,55 +90,30 @@ public UpdateControl reconcile(WebPage webPage, Context contex return UpdateControl.patchStatus(setInvalidHtmlErrorMessage(webPage)); } - String ns = webPage.getMetadata().getNamespace(); - String configMapName = configMapName(webPage); - String deploymentName = deploymentName(webPage); + ConfigMap desiredHtmlConfigMap = makeDesiredHtmlConfigMap(webPage); + Deployment desiredDeployment = makeDesiredDeployment(webPage); + Service desiredService = makeDesiredService(webPage, desiredDeployment); - ConfigMap desiredHtmlConfigMap = makeDesiredHtmlConfigMap(ns, configMapName, webPage); - Deployment desiredDeployment = - makeDesiredDeployment(webPage, deploymentName, ns, configMapName); - Service desiredService = makeDesiredService(webPage, ns, desiredDeployment); - - var previousConfigMap = context.getSecondaryResource(ConfigMap.class).orElse(null); - if (!match(desiredHtmlConfigMap, previousConfigMap)) { - log.info( - "Creating or updating ConfigMap {} in {}", - desiredHtmlConfigMap.getMetadata().getName(), - ns); - context.resourceOperations().serverSideApply(desiredHtmlConfigMap); - } - - var existingDeployment = context.getSecondaryResource(Deployment.class).orElse(null); - if (!match(desiredDeployment, existingDeployment)) { - log.info( - "Creating or updating Deployment {} in {}", - desiredDeployment.getMetadata().getName(), - ns); - context.resourceOperations().serverSideApply(desiredDeployment); - } - - var existingService = context.getSecondaryResource(Service.class).orElse(null); - if (!match(desiredService, existingService)) { - log.info( - "Creating or updating Service {} in {}", desiredDeployment.getMetadata().getName(), ns); - context.resourceOperations().serverSideApply(desiredService); - } + final var previousConfigMap = createOrUpdate(context, desiredHtmlConfigMap, this::match); + createOrUpdate(context, desiredDeployment, this::match); + createOrUpdate(context, desiredService, this::match); var existingIngress = context.getSecondaryResource(Ingress.class); if (Boolean.TRUE.equals(webPage.getSpec().getExposed())) { var desiredIngress = makeDesiredIngress(webPage); if (existingIngress.isEmpty() || !match(desiredIngress, existingIngress.get())) { - context.resourceOperations().serverSideApply(desiredDeployment); + context.resourceOperations().serverSideApply(desiredIngress); } } else existingIngress.ifPresent(ingress -> context.getClient().resource(ingress).delete()); // not that this is not necessary, eventually mounted config map would be updated, just this way - // is much faster; what is handy for demo purposes. + // is much faster; this is handy for demo purposes. // https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/#mounted-configmaps-are-updated-automatically if (previousConfigMap != null - && !StringUtils.equals( + && !Objects.equals( previousConfigMap.getData().get(INDEX_HTML), desiredHtmlConfigMap.getData().get(INDEX_HTML))) { + final var ns = webPage.getMetadata().getNamespace(); log.info("Restarting pods because HTML has changed in {}", ns); context.getClient().pods().inNamespace(ns).withLabel("app", deploymentName(webPage)).delete(); } @@ -147,6 +122,21 @@ public UpdateControl reconcile(WebPage webPage, Context contex createWebPageForStatusUpdate(webPage, desiredHtmlConfigMap.getMetadata().getName())); } + private T createOrUpdate( + Context context, T desired, BiFunction matcher) { + @SuppressWarnings("unchecked") + final T previous = (T) context.getSecondaryResource(desired.getClass()).orElse(null); + if (!matcher.apply(desired, previous)) { + log.info( + "Creating or updating {} {} in {}", + desired.getKind(), + desired.getMetadata().getName(), + desired.getMetadata().getNamespace()); + context.resourceOperations().serverSideApply(desired); + } + return previous; + } + private boolean match(Ingress desiredIngress, Ingress existingIngress) { String desiredServiceName = desiredIngress @@ -205,9 +195,10 @@ private boolean match(ConfigMap desiredHtmlConfigMap, ConfigMap existingConfigMa } } - private Service makeDesiredService(WebPage webPage, String ns, Deployment desiredDeployment) { + private Service makeDesiredService(WebPage webPage, Deployment desiredDeployment) { Service desiredService = ReconcilerUtilsInternal.loadYaml(Service.class, getClass(), "service.yaml"); + final var ns = webPage.getMetadata().getNamespace(); desiredService.getMetadata().setName(serviceName(webPage)); desiredService.getMetadata().setNamespace(ns); desiredService.getMetadata().setLabels(lowLevelLabel()); @@ -218,15 +209,18 @@ private Service makeDesiredService(WebPage webPage, String ns, Deployment desire return desiredService; } - private Deployment makeDesiredDeployment( - WebPage webPage, String deploymentName, String ns, String configMapName) { + private Deployment makeDesiredDeployment(WebPage webPage) { Deployment desiredDeployment = ReconcilerUtilsInternal.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + final var ns = webPage.getMetadata().getNamespace(); + final var deploymentName = deploymentName(webPage); desiredDeployment.getMetadata().setName(deploymentName); desiredDeployment.getMetadata().setNamespace(ns); desiredDeployment.getMetadata().setLabels(lowLevelLabel()); desiredDeployment.getSpec().getSelector().getMatchLabels().put("app", deploymentName); desiredDeployment.getSpec().getTemplate().getMetadata().getLabels().put("app", deploymentName); + + final var configMapName = configMapName(webPage); desiredDeployment .getSpec() .getTemplate() @@ -238,7 +232,9 @@ private Deployment makeDesiredDeployment( return desiredDeployment; } - private ConfigMap makeDesiredHtmlConfigMap(String ns, String configMapName, WebPage webPage) { + private ConfigMap makeDesiredHtmlConfigMap(WebPage webPage) { + final var ns = webPage.getMetadata().getNamespace(); + final var configMapName = configMapName(webPage); Map data = new HashMap<>(); data.put(INDEX_HTML, webPage.getSpec().getHtml()); ConfigMap configMap = From d078452f14a8f7188e2f3ee6ff9a6bd0d156e138 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Mon, 23 Feb 2026 13:13:10 +0100 Subject: [PATCH 23/58] chore: set next version to 999-SNAPSHOT (#3180) Fixes #3175 Signed-off-by: Chris Laprun --- bootstrapper-maven-plugin/pom.xml | 2 +- caffeine-bounded-cache-support/pom.xml | 2 +- micrometer-support/pom.xml | 2 +- operator-framework-bom/pom.xml | 2 +- operator-framework-core/pom.xml | 2 +- operator-framework-junit/pom.xml | 2 +- operator-framework/pom.xml | 2 +- pom.xml | 2 +- sample-operators/controller-namespace-deletion/pom.xml | 2 +- sample-operators/leader-election/pom.xml | 2 +- sample-operators/mysql-schema/pom.xml | 2 +- sample-operators/pom.xml | 2 +- sample-operators/tomcat-operator/pom.xml | 2 +- sample-operators/webpage/pom.xml | 2 +- test-index-processor/pom.xml | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml index 48140ccd0b..20bfa91885 100644 --- a/bootstrapper-maven-plugin/pom.xml +++ b/bootstrapper-maven-plugin/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT bootstrapper diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index ec0a2ef634..be70ab9a2e 100644 --- a/caffeine-bounded-cache-support/pom.xml +++ b/caffeine-bounded-cache-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT caffeine-bounded-cache-support diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index 87ece72ec3..ae3c4d0be1 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT micrometer-support diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 93949d794b..ccfb2f6266 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk operator-framework-bom - 5.3.0-SNAPSHOT + 999-SNAPSHOT pom Operator SDK - Bill of Materials Java SDK for implementing Kubernetes operators diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index 72fe2e8188..2356433ca9 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT ../pom.xml diff --git a/operator-framework-junit/pom.xml b/operator-framework-junit/pom.xml index 592a243dd9..aa18d5c778 100644 --- a/operator-framework-junit/pom.xml +++ b/operator-framework-junit/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT operator-framework-junit diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index 1c9849dd65..f94dfa757d 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT operator-framework diff --git a/pom.xml b/pom.xml index 0a99cacd1e..9e52d87be1 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT pom Operator SDK for Java Java SDK for implementing Kubernetes operators diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index 98b29557ac..4cdfe1f2bd 100644 --- a/sample-operators/controller-namespace-deletion/pom.xml +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.0-SNAPSHOT + 999-SNAPSHOT sample-controller-namespace-deletion diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index 95d19d4db5..8194b433fc 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.0-SNAPSHOT + 999-SNAPSHOT sample-leader-election diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index 7df2a0d417..ea4f50256e 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.0-SNAPSHOT + 999-SNAPSHOT sample-mysql-schema-operator diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 6079d3bb71..374fb30a73 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT sample-operators diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 5719c3a4f1..b7c3b05c98 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.0-SNAPSHOT + 999-SNAPSHOT sample-tomcat-operator diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 6ec60340ae..c50366e37e 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.0-SNAPSHOT + 999-SNAPSHOT sample-webpage-operator diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml index 5ea4008f78..2ae7c5f454 100644 --- a/test-index-processor/pom.xml +++ b/test-index-processor/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.0-SNAPSHOT + 999-SNAPSHOT test-index-processor From 8b768ee5966a8507deae54c44ea7de8bd104d1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 16:22:30 +0100 Subject: [PATCH 24/58] feat: allow to skip namespace deletion in junit extension (#3178) --- .../junit/AbstractOperatorExtension.java | 37 +++++++++++++------ .../ClusterDeployedOperatorExtension.java | 3 ++ .../junit/LocallyRunOperatorExtension.java | 3 ++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java index eceb6d9d76..0609850713 100644 --- a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java @@ -58,6 +58,7 @@ public abstract class AbstractOperatorExtension protected Duration infrastructureTimeout; protected final boolean oneNamespacePerClass; protected final boolean preserveNamespaceOnError; + protected final boolean skipNamespaceDeletion; protected final boolean waitForNamespaceDeletion; protected final int namespaceDeleteTimeout = DEFAULT_NAMESPACE_DELETE_TIMEOUT; protected final Function namespaceNameSupplier; @@ -70,6 +71,7 @@ protected AbstractOperatorExtension( Duration infrastructureTimeout, boolean oneNamespacePerClass, boolean preserveNamespaceOnError, + boolean skipNamespaceDeletion, boolean waitForNamespaceDeletion, KubernetesClient kubernetesClient, KubernetesClient infrastructureKubernetesClient, @@ -85,6 +87,7 @@ protected AbstractOperatorExtension( this.infrastructureTimeout = infrastructureTimeout; this.oneNamespacePerClass = oneNamespacePerClass; this.preserveNamespaceOnError = preserveNamespaceOnError; + this.skipNamespaceDeletion = skipNamespaceDeletion; this.waitForNamespaceDeletion = waitForNamespaceDeletion; this.namespaceNameSupplier = namespaceNameSupplier; this.perClassNamespaceNameSupplier = perClassNamespaceNameSupplier; @@ -202,19 +205,22 @@ protected void after(ExtensionContext context) { if (preserveNamespaceOnError && context.getExecutionException().isPresent()) { LOGGER.info("Preserving namespace {}", namespace); } else { + LOGGER.info("Deleting infrastructure resources and operator in namespace {}", namespace); infrastructureKubernetesClient.resourceList(infrastructure).delete(); deleteOperator(); - LOGGER.info("Deleting namespace {} and stopping operator", namespace); - infrastructureKubernetesClient.namespaces().withName(namespace).delete(); - if (waitForNamespaceDeletion) { - LOGGER.info("Waiting for namespace {} to be deleted", namespace); - Awaitility.await("namespace deleted") - .pollInterval(50, TimeUnit.MILLISECONDS) - .atMost(namespaceDeleteTimeout, TimeUnit.SECONDS) - .until( - () -> - infrastructureKubernetesClient.namespaces().withName(namespace).get() - == null); + if (!skipNamespaceDeletion) { + LOGGER.info("Deleting namespace {}", namespace); + infrastructureKubernetesClient.namespaces().withName(namespace).delete(); + if (waitForNamespaceDeletion) { + LOGGER.info("Waiting for namespace {} to be deleted", namespace); + Awaitility.await("namespace deleted") + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(namespaceDeleteTimeout, TimeUnit.SECONDS) + .until( + () -> + infrastructureKubernetesClient.namespaces().withName(namespace).get() + == null); + } } } } @@ -229,6 +235,7 @@ public abstract static class AbstractBuilder> { protected final List infrastructure; protected Duration infrastructureTimeout; protected boolean preserveNamespaceOnError; + protected boolean skipNamespaceDeletion; protected boolean waitForNamespaceDeletion; protected boolean oneNamespacePerClass; protected int namespaceDeleteTimeout; @@ -245,6 +252,9 @@ protected AbstractBuilder() { this.preserveNamespaceOnError = Utils.getSystemPropertyOrEnvVar("josdk.it.preserveNamespaceOnError", false); + this.skipNamespaceDeletion = + Utils.getSystemPropertyOrEnvVar("josdk.it.skipNamespaceDeletion", false); + this.waitForNamespaceDeletion = Utils.getSystemPropertyOrEnvVar("josdk.it.waitForNamespaceDeletion", true); @@ -261,6 +271,11 @@ public T preserveNamespaceOnError(boolean value) { return (T) this; } + public T skipNamespaceDeletion(boolean value) { + this.skipNamespaceDeletion = value; + return (T) this; + } + public T waitForNamespaceDeletion(boolean value) { this.waitForNamespaceDeletion = value; return (T) this; diff --git a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java index 2f134fa5ff..bcca851afe 100644 --- a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java @@ -51,6 +51,7 @@ private ClusterDeployedOperatorExtension( List infrastructure, Duration infrastructureTimeout, boolean preserveNamespaceOnError, + boolean skipNamespaceDeletion, boolean waitForNamespaceDeletion, boolean oneNamespacePerClass, KubernetesClient kubernetesClient, @@ -62,6 +63,7 @@ private ClusterDeployedOperatorExtension( infrastructureTimeout, oneNamespacePerClass, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, kubernetesClient, infrastructureKubernetesClient, @@ -189,6 +191,7 @@ public ClusterDeployedOperatorExtension build() { infrastructure, infrastructureTimeout, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, oneNamespacePerClass, kubernetesClient, diff --git a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java index a1cdef8309..c4ed4c9e66 100644 --- a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -80,6 +80,7 @@ private LocallyRunOperatorExtension( List additionalCustomResourceDefinitionInstances, Duration infrastructureTimeout, boolean preserveNamespaceOnError, + boolean skipNamespaceDeletion, boolean waitForNamespaceDeletion, boolean oneNamespacePerClass, KubernetesClient kubernetesClient, @@ -94,6 +95,7 @@ private LocallyRunOperatorExtension( infrastructureTimeout, oneNamespacePerClass, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, kubernetesClient, infrastructureKubernetesClient, @@ -541,6 +543,7 @@ public LocallyRunOperatorExtension build() { additionalCustomResourceDefinitionInstances, infrastructureTimeout, preserveNamespaceOnError, + skipNamespaceDeletion, waitForNamespaceDeletion, oneNamespacePerClass, kubernetesClient, From 1c24e0df4286996f523bb2b470c5865cb44a8b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 18:03:15 +0100 Subject: [PATCH 25/58] improve: logging for resource filter cache (#3167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros Co-authored-by: Chris Laprun --- .../en/docs/documentation/observability.md | 22 ++++++ .../operator/processing/MDCUtils.java | 67 ++++++++++++++++ .../processing/event/EventProcessor.java | 43 ++++------ .../event/ReconciliationDispatcher.java | 42 +++------- .../controller/ControllerEventSource.java | 51 ++++++------ .../informer/ExtendedResourceEvent.java | 32 +++++++- .../source/informer/InformerEventSource.java | 78 +++++++++---------- .../informer/ManagedInformerEventSource.java | 24 ++++-- .../informer/TemporaryResourceCache.java | 14 ++-- .../src/test/resources/log4j2.xml | 2 +- .../src/test/resources/log4j2.xml | 2 +- .../src/main/resources/log4j2.xml | 2 +- .../src/main/resources/log4j2.xml | 2 +- .../src/main/resources/log4j2.xml | 4 +- .../src/main/resources/log4j2.xml | 4 +- .../webpage/src/main/resources/log4j2.xml | 2 +- 16 files changed, 242 insertions(+), 149 deletions(-) diff --git a/docs/content/en/docs/documentation/observability.md b/docs/content/en/docs/documentation/observability.md index 312c31967e..acadaea182 100644 --- a/docs/content/en/docs/documentation/observability.md +++ b/docs/content/en/docs/documentation/observability.md @@ -33,6 +33,28 @@ parts of reconciliation logic and during the execution of the controller: For more information about MDC see this [link](https://www.baeldung.com/mdc-in-log4j-2-logback). +### MDC entries during event handling + +Although, usually users might not require it in their day-to-day workflow, it is worth mentioning that +there are additional MDC entries managed for event handling. Typically, you might be interested in it +in your `SecondaryToPrimaryMapper` related logs. +For `InformerEventSource` and `ControllerEventSource` the following information is present: + +| MDC Key | Value from Resource from the Event | +|:-----------------------------------------------|:-------------------------------------------------| +| `eventsource.event.resource.name` | `.metadata.name` | +| `eventsource.event.resource.uid` | `.metadata.uid` | +| `eventsource.event.resource.namespace` | `.metadata.namespace` | +| `eventsource.event.resource.kind` | resource kind | +| `eventsource.event.resource.resourceVersion` | `.metadata.resourceVersion` | +| `eventsource.event.action` | action name (e.g. `ADDED`, `UPDATED`, `DELETED`) | +| `eventsource.name` | name of the event source | + +### Disabling MDC support + +MDC support is enabled by default. If you want to disable it, you can set the `JAVA_OPERATOR_SDK_USE_MDC` environment +variable to `false` when you start your operator. + ## Metrics JOSDK provides built-in support for metrics reporting on what is happening with your reconcilers in the form of diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java index 01a8b62e9d..68043be070 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java @@ -20,6 +20,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.Utils; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; public class MDCUtils { @@ -34,6 +35,72 @@ public class MDCUtils { private static final boolean enabled = Utils.getBooleanFromSystemPropsOrDefault(Utils.USE_MDC_ENV_KEY, true); + private static final String EVENT_RESOURCE_NAME = "eventsource.event.resource.name"; + private static final String EVENT_RESOURCE_UID = "eventsource.event.resource.uid"; + private static final String EVENT_RESOURCE_NAMESPACE = "eventsource.event.resource.namespace"; + private static final String EVENT_RESOURCE_KIND = "eventsource.event.resource.kind"; + private static final String EVENT_RESOURCE_VERSION = "eventsource.event.resource.resourceVersion"; + private static final String EVENT_ACTION = "eventsource.event.action"; + private static final String EVENT_SOURCE_NAME = "eventsource.name"; + + public static void addInformerEventInfo( + HasMetadata resource, ResourceAction action, String eventSourceName) { + if (enabled) { + MDC.put(EVENT_RESOURCE_NAME, resource.getMetadata().getName()); + MDC.put(EVENT_RESOURCE_NAMESPACE, resource.getMetadata().getNamespace()); + MDC.put(EVENT_RESOURCE_KIND, HasMetadata.getKind(resource.getClass())); + MDC.put(EVENT_RESOURCE_VERSION, resource.getMetadata().getResourceVersion()); + MDC.put(EVENT_RESOURCE_UID, resource.getMetadata().getUid()); + MDC.put(EVENT_ACTION, action == null ? null : action.name()); + MDC.put(EVENT_SOURCE_NAME, eventSourceName); + } + } + + public static void removeInformerEventInfo() { + if (enabled) { + MDC.remove(EVENT_RESOURCE_NAME); + MDC.remove(EVENT_RESOURCE_NAMESPACE); + MDC.remove(EVENT_RESOURCE_KIND); + MDC.remove(EVENT_RESOURCE_VERSION); + MDC.remove(EVENT_RESOURCE_UID); + MDC.remove(EVENT_ACTION); + MDC.remove(EVENT_SOURCE_NAME); + } + } + + public static void withMDCForEvent( + HasMetadata resource, Runnable runnable, String eventSourceName) { + withMDCForEvent(resource, null, runnable, eventSourceName); + } + + public static void withMDCForEvent( + HasMetadata resource, ResourceAction action, Runnable runnable, String eventSourceName) { + try { + MDCUtils.addInformerEventInfo(resource, action, eventSourceName); + runnable.run(); + } finally { + MDCUtils.removeInformerEventInfo(); + } + } + + public static void withMDCForResourceID(ResourceID resourceID, Runnable runnable) { + try { + MDCUtils.addResourceIDInfo(resourceID); + runnable.run(); + } finally { + MDCUtils.removeResourceIDInfo(); + } + } + + public static void withMDCForPrimary(HasMetadata primary, Runnable runnable) { + try { + MDCUtils.addResourceInfo(primary); + runnable.run(); + } finally { + MDCUtils.removeResourceInfo(); + } + } + public static void addResourceIDInfo(ResourceID resourceID) { if (enabled) { MDC.put(NAME, resourceID.getName()); 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 b476c39614..6c9aee67c1 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 @@ -44,8 +44,6 @@ import io.javaoperatorsdk.operator.processing.retry.Retry; import io.javaoperatorsdk.operator.processing.retry.RetryExecution; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getName; - public class EventProcessor

implements EventHandler, LifecycleAware { private static final Logger log = LoggerFactory.getLogger(EventProcessor.class); @@ -187,9 +185,8 @@ private void submitReconciliationExecution(ResourceState state) { executor.execute(new ReconcilerExecutor(resourceID, executionScope)); } else { log.debug( - "Skipping executing controller for resource id: {}. Controller in execution: {}. Latest" + "Skipping executing controller. Controller in execution: {}. Latest" + " Resource present: {}", - resourceID, controllerUnderExecution, maybeLatest.isPresent()); if (maybeLatest.isEmpty()) { @@ -198,7 +195,7 @@ private void submitReconciliationExecution(ResourceState state) { // resource. Other is that simply there is no primary resource present for an event, this // might indicate issue with the implementation, but could happen also naturally, thus // this is not necessarily a problem. - log.debug("no primary resource found in cache with resource id: {}", resourceID); + log.debug("No primary resource found in cache with resource id: {}", resourceID); } } } finally { @@ -209,7 +206,7 @@ private void submitReconciliationExecution(ResourceState state) { @SuppressWarnings("unchecked") private P getResourceFromState(ResourceState state) { if (triggerOnAllEvents()) { - log.debug("Getting resource from state for {}", state.getId()); + log.debug("Getting resource from state"); return (P) state.getLastKnownResource(); } else { throw new IllegalStateException( @@ -218,10 +215,9 @@ private P getResourceFromState(ResourceState state) { } private void handleEventMarking(Event event, ResourceState state) { - final var relatedCustomResourceID = event.getRelatedCustomResourceID(); if (event instanceof ResourceEvent resourceEvent) { if (resourceEvent.getAction() == ResourceAction.DELETED) { - log.debug("Marking delete event received for: {}", relatedCustomResourceID); + log.debug("Marking delete event received"); state.markDeleteEventReceived( resourceEvent.getResource().orElseThrow(), ((ResourceDeleteEvent) resourceEvent).isDeletedFinalStateUnknown()); @@ -229,8 +225,7 @@ private void handleEventMarking(Event event, ResourceState state) { if (state.processedMarkForDeletionPresent() && isResourceMarkedForDeletion(resourceEvent)) { log.debug( "Skipping mark of event received, since already processed mark for deletion and" - + " resource marked for deletion: {}", - relatedCustomResourceID); + + " resource marked for deletion"); return; } // Normally when eventMarker is in state PROCESSED_MARK_FOR_DELETION it is expected to @@ -260,8 +255,7 @@ private boolean isResourceMarkedForDeletion(ResourceEvent resourceEvent) { private void handleRateLimitedSubmission(ResourceID resourceID, Duration minimalDuration) { var minimalDurationMillis = minimalDuration.toMillis(); - log.debug( - "Rate limited resource: {}, rescheduled in {} millis", resourceID, minimalDurationMillis); + log.debug("Rate limited resource; rescheduled in {} millis", minimalDurationMillis); retryEventSource() .scheduleOnce( resourceID, Math.max(minimalDurationMillis, MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION)); @@ -334,7 +328,7 @@ private void reScheduleExecutionIfInstructed( .ifPresentOrElse( delay -> { var resourceID = ResourceID.fromResource(customResource); - log.debug("Rescheduling event for resource: {} with delay: {}", resourceID, delay); + log.debug("Rescheduling event with delay: {}", delay); retryEventSource().scheduleOnce(resourceID, delay); }, () -> scheduleExecutionForMaxReconciliationInterval(customResource)); @@ -347,11 +341,7 @@ private void scheduleExecutionForMaxReconciliationInterval(P customResource) { m -> { var resourceID = ResourceID.fromResource(customResource); var delay = m.toMillis(); - log.debug( - "Rescheduling event for max reconciliation interval for resource: {} : " - + "with delay: {}", - resourceID, - delay); + log.debug("Rescheduling event for max reconciliation interval with delay: {}", delay); retryEventSource().scheduleOnce(resourceID, delay); }); } @@ -375,7 +365,7 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception retryAwareErrorLogging(state.getRetry(), eventPresent, exception, executionScope); if (eventPresent) { - log.debug("New events exists for for resource id: {}", resourceID); + log.debug("New events exist for resource id"); submitReconciliationExecution(state); return; } @@ -383,8 +373,7 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception nextDelay.ifPresentOrElse( delay -> { - log.debug( - "Scheduling timer event for retry with delay:{} for resource: {}", delay, resourceID); + log.debug("Scheduling timer event for retry with delay:{}", delay); metrics.failedReconciliation(executionScope.getResource(), exception, metricsMetadata); retryEventSource().scheduleOnce(resourceID, delay); }, @@ -425,8 +414,7 @@ private void retryAwareErrorLogging( } private void cleanupOnSuccessfulExecution(ExecutionScope

executionScope) { - log.debug( - "Cleanup for successful execution for resource: {}", getName(executionScope.getResource())); + log.debug("Cleanup for successful execution"); if (isRetryConfigured()) { resourceStateManager.getOrCreate(executionScope.getResourceID()).setRetry(null); } @@ -444,7 +432,7 @@ private ResourceState getOrInitRetryExecution(ExecutionScope

executionScope) } private void cleanupForDeletedEvent(ResourceID resourceID) { - log.debug("Cleaning up for delete event for: {}", resourceID); + log.debug("Cleaning up for delete event"); resourceStateManager.remove(resourceID); metrics.cleanupDoneFor(resourceID, metricsMetadata); } @@ -509,6 +497,7 @@ public void run() { log.debug("Event processor not running skipping resource processing: {}", resourceID); return; } + MDCUtils.addResourceIDInfo(resourceID); log.debug("Running reconcile executor for: {}", executionScope); // change thread name for easier debugging final var thread = Thread.currentThread(); @@ -518,9 +507,7 @@ public void run() { var actualResource = cache.get(resourceID); if (actualResource.isEmpty()) { if (triggerOnAllEvents()) { - log.debug( - "Resource not found in the cache, checking for delete event resource: {}", - resourceID); + log.debug("Resource not found in the cache, checking for delete event resource"); if (executionScope.isDeleteEvent()) { var state = resourceStateManager.get(resourceID); actualResource = @@ -538,7 +525,7 @@ public void run() { return; } } else { - log.debug("Skipping execution; primary resource missing from cache: {}", resourceID); + log.debug("Skipping execution; primary resource missing from cache"); return; } } 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 010b161979..6e7ace0447 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 @@ -83,19 +83,13 @@ PostExecutionControl

handleDispatch(ExecutionScope

executionScope, Context throws Exception { P originalResource = executionScope.getResource(); var resourceForExecution = cloneResource(originalResource); - log.debug( - "Handling dispatch for resource name: {} namespace: {}", - getName(originalResource), - originalResource.getMetadata().getNamespace()); + log.debug("Handling dispatch"); final var markedForDeletion = originalResource.isMarkedForDeletion(); if (!triggerOnAllEvents() && markedForDeletion && shouldNotDispatchToCleanupWhenMarkedForDeletion(originalResource)) { - log.debug( - "Skipping cleanup of resource {} because finalizer(s) {} don't allow processing yet", - getName(originalResource), - originalResource.getMetadata().getFinalizers()); + log.debug("Skipping cleanup because finalizer(s) don't allow processing yet"); return PostExecutionControl.defaultDispatch(); } // context can be provided only for testing purposes @@ -165,11 +159,7 @@ private PostExecutionControl

reconcileExecution( P originalResource, Context

context) throws Exception { - log.debug( - "Reconciling resource {} with version: {} with execution scope: {}", - getName(resourceForExecution), - getVersion(resourceForExecution), - executionScope); + log.debug("Reconciling resource execution scope: {}", executionScope); UpdateControl

updateControl = controller.reconcile(resourceForExecution, context); @@ -246,9 +236,8 @@ public boolean isLastAttempt() { exceptionLevel = Level.DEBUG; failedMessage = " due to conflict"; log.info( - "ErrorStatusUpdateControl.patchStatus of {} failed due to a conflict, but the next" - + " reconciliation is imminent.", - ResourceID.fromResource(originalResource)); + "ErrorStatusUpdateControl.patchStatus failed due to a conflict, but the next" + + " reconciliation is imminent"); } else { exceptionLevel = Level.WARN; failedMessage = ", but will be retried soon,"; @@ -312,10 +301,7 @@ private void updatePostExecutionControlWithReschedule( private PostExecutionControl

handleCleanup( P resourceForExecution, Context

context, ExecutionScope

executionScope) { if (log.isDebugEnabled()) { - log.debug( - "Executing delete for resource: {} with version: {}", - ResourceID.fromResource(resourceForExecution), - getVersion(resourceForExecution)); + log.debug("Executing delete for resource"); } DeleteControl deleteControl = controller.cleanup(resourceForExecution, context); final var useFinalizer = controller.useFinalizer(); @@ -329,10 +315,7 @@ private PostExecutionControl

handleCleanup( } } log.debug( - "Skipping finalizer remove for resource: {} with version: {}. delete control: {}, uses" - + " finalizer: {}", - getUID(resourceForExecution), - getVersion(resourceForExecution), + "Skipping finalizer remove for resource. Delete control: {}, uses finalizer: {}", deleteControl, useFinalizer); PostExecutionControl

postExecutionControl = PostExecutionControl.defaultDispatch(); @@ -342,11 +325,7 @@ private PostExecutionControl

handleCleanup( 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); + log.debug("Updating resource; with SSA: {}", useSSA); } log.trace("Resource before update: {}", resource); @@ -384,10 +363,7 @@ public CustomResourceFacade(ControllerConfiguration configuration, Cloner clo public R patchResource(Context context, R resource, R originalResource) { if (log.isDebugEnabled()) { - log.debug( - "Trying to replace resource {}, version: {}", - ResourceID.fromResource(resource), - resource.getMetadata().getResourceVersion()); + log.debug("Trying to replace resource"); } if (useSSA) { return context.resourceOperations().serverSideApplyPrimary(resource); 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 8412e1ccbe..e0682d5808 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 @@ -35,7 +35,6 @@ import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; 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.*; public class ControllerEventSource @@ -88,12 +87,7 @@ protected synchronized void handleEvent( ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) { try { if (log.isDebugEnabled()) { - log.debug( - "Event received for resource: {} version: {} uuid: {} action: {}", - ResourceID.fromResource(resource), - getVersion(resource), - resource.getMetadata().getUid(), - action); + log.debug("Event received with action: {}", action); log.trace("Event Old resource: {},\n new resource: {}", oldResource, resource); } MDCUtils.addResourceInfo(resource); @@ -112,7 +106,7 @@ protected synchronized void handleEvent( .handleEvent(new ResourceEvent(action, ResourceID.fromResource(resource), resource)); } } else { - log.debug("Skipping event handling resource {}", ResourceID.fromResource(resource)); + log.debug("Skipping event handling for resource"); } } finally { MDCUtils.removeResourceInfo(); @@ -124,23 +118,27 @@ private boolean isAcceptedByFilters(ResourceAction action, T resource, T oldReso if (genericFilter != null && !genericFilter.accept(resource)) { return false; } - switch (action) { - case ADDED: - return onAddFilter == null || onAddFilter.accept(resource); - case UPDATED: - return onUpdateFilter.accept(resource, oldResource); - } - return true; + return switch (action) { + case ADDED -> onAddFilter == null || onAddFilter.accept(resource); + case UPDATED -> onUpdateFilter.accept(resource, oldResource); + default -> true; + }; } @Override public synchronized void onAdd(T resource) { - handleOnAddOrUpdate(ResourceAction.ADDED, null, resource); + withMDC( + resource, + ResourceAction.ADDED, + () -> handleOnAddOrUpdate(ResourceAction.ADDED, null, resource)); } @Override public synchronized void onUpdate(T oldCustomResource, T newCustomResource) { - handleOnAddOrUpdate(ResourceAction.UPDATED, oldCustomResource, newCustomResource); + withMDC( + newCustomResource, + ResourceAction.UPDATED, + () -> handleOnAddOrUpdate(ResourceAction.UPDATED, oldCustomResource, newCustomResource)); } private void handleOnAddOrUpdate( @@ -150,20 +148,21 @@ private void handleOnAddOrUpdate( 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)); + log.debug("{} event propagation for action: {}", handling, action); } } @Override 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); + withMDC( + resource, + ResourceAction.DELETED, + () -> { + 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/informer/ExtendedResourceEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java index 4ae476a3de..5d30d1b0e1 100644 --- 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 @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.Objects; import java.util.Optional; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -25,7 +26,7 @@ /** Used only for resource event filtering. */ public class ExtendedResourceEvent extends ResourceEvent { - private HasMetadata previousResource; + private final HasMetadata previousResource; public ExtendedResourceEvent( ResourceAction action, @@ -39,4 +40,33 @@ public ExtendedResourceEvent( public Optional getPreviousResource() { return Optional.ofNullable(previousResource); } + + @Override + public String toString() { + return "ExtendedResourceEvent{" + + getPreviousResource() + .map(r -> "previousResourceVersion=" + r.getMetadata().getResourceVersion()) + .orElse("") + + ", action=" + + getAction() + + getResource() + .map(r -> ", resourceVersion=" + r.getMetadata().getResourceVersion()) + .orElse("") + + ", relatedCustomResourceName=" + + getRelatedCustomResourceID().getName() + + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + ExtendedResourceEvent that = (ExtendedResourceEvent) o; + return Objects.equals(previousResource, that.previousResource); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), 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 b778747417..fcec8ae68b 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 @@ -100,42 +100,48 @@ private InformerEventSource( @Override public void onAdd(R newResource) { - if (log.isDebugEnabled()) { - log.debug( - "On add event received for resource id: {} type: {} version: {}", - ResourceID.fromResource(newResource), - resourceType().getSimpleName(), - newResource.getMetadata().getResourceVersion()); - } - onAddOrUpdate(ResourceAction.ADDED, newResource, null); + withMDC( + newResource, + ResourceAction.ADDED, + () -> { + if (log.isDebugEnabled()) { + log.debug("On add event received"); + } + onAddOrUpdate(ResourceAction.ADDED, newResource, null); + }); } @Override public void onUpdate(R oldObject, R newObject) { - if (log.isDebugEnabled()) { - log.debug( - "On update event received for resource id: {} type: {} version: {} old version: {} ", - ResourceID.fromResource(newObject), - resourceType().getSimpleName(), - newObject.getMetadata().getResourceVersion(), - oldObject.getMetadata().getResourceVersion()); - } - onAddOrUpdate(ResourceAction.UPDATED, newObject, oldObject); + withMDC( + newObject, + ResourceAction.UPDATED, + () -> { + if (log.isDebugEnabled()) { + log.debug( + "On update event received. Old version: {}", + oldObject.getMetadata().getResourceVersion()); + } + onAddOrUpdate(ResourceAction.UPDATED, newObject, oldObject); + }); } @Override - public synchronized void onDelete(R resource, boolean b) { - if (log.isDebugEnabled()) { - log.debug( - "On delete event received for resource id: {} type: {}", - ResourceID.fromResource(resource), - resourceType().getSimpleName()); - } - primaryToSecondaryIndex.onDelete(resource); - temporaryResourceCache.onDeleteEvent(resource, b); - if (acceptedByDeleteFilters(resource, b)) { - propagateEvent(resource); - } + public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) { + withMDC( + resource, + ResourceAction.DELETED, + () -> { + if (log.isDebugEnabled()) { + log.debug( + "On delete event received. deletedFinalStateUnknown: {}", deletedFinalStateUnknown); + } + primaryToSecondaryIndex.onDelete(resource); + temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown); + if (acceptedByDeleteFilters(resource, deletedFinalStateUnknown)) { + propagateEvent(resource); + } + }); } @Override @@ -160,16 +166,11 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol if (eventHandling != EventHandling.NEW) { log.debug( - "{} event propagation for {}. Resource ID: {}", - eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping", - action, - ResourceID.fromResource(newObject)); + "{} event propagation", eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping"); } else if (eventAcceptedByFilter(action, newObject, oldObject)) { log.debug( - "Propagating event for {}, resource with same version not result of a reconciliation." - + " Resource ID: {}", - action, - resourceID); + "Propagating event for {}, resource with same version not result of a reconciliation.", + action); propagateEvent(newObject); } else { log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); @@ -211,9 +212,8 @@ public Set getSecondaryResources(P primary) { } else { secondaryIDs = primaryToSecondaryMapper.toSecondaryResourceIDs(primary); log.debug( - "Using PrimaryToSecondaryMapper to find secondary resources for primary: {}. Found" + "Using PrimaryToSecondaryMapper to find secondary resources for primary. Found" + " secondary ids: {} ", - primary, secondaryIDs); } return secondaryIDs.stream() 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 301ece4424..38c93d03ae 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 @@ -40,6 +40,7 @@ import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.MDCUtils; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.*; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; @@ -93,11 +94,12 @@ public void changeNamespaces(Set namespaces) { @SuppressWarnings("unchecked") public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { ResourceID id = ResourceID.fromResource(resourceToUpdate); - log.debug("Update and cache: {}", id); + log.debug("Starting event filtering and caching update"); R updatedResource = null; try { temporaryResourceCache.startEventFilteringModify(id); updatedResource = updateMethod.apply(resourceToUpdate); + log.debug("Resource update successful"); handleRecentResourceUpdate(id, updatedResource, resourceToUpdate); return updatedResource; } finally { @@ -113,17 +115,23 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< // 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. + // 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. + // actual event in the ExtendedResourceEvent. R extendedResourcePrevVersion = (r instanceof ExtendedResourceEvent) ? (R) ((ExtendedResourceEvent) r).getPreviousResource().orElse(null) : null; R prevVersionOfResource = updatedForLambda != null ? updatedForLambda : extendedResourcePrevVersion; + if (log.isDebugEnabled()) { + log.debug( + "Previous resource version: {} resource from update present: {}" + + " extendedPrevResource present: {}", + prevVersionOfResource.getMetadata().getResourceVersion(), + updatedForLambda != null, + extendedResourcePrevVersion != null); + } handleEvent( r.getAction(), latestResource, @@ -132,7 +140,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< ? ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown() : null); }, - () -> log.debug("No new event present after the filtering update; id: {}", id)); + () -> log.debug("No new event present after the filtering update")); } } @@ -257,4 +265,8 @@ public String toString() { public void setControllerConfiguration(ControllerConfiguration controllerConfiguration) { this.controllerConfiguration = controllerConfiguration; } + + protected void withMDC(R resource, ResourceAction action, Runnable runnable) { + MDCUtils.withMDCForEvent(resource, action, runnable, name()); + } } 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 1dbbf36043..43d9dc1fab 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 @@ -107,9 +107,6 @@ public void onDeleteEvent(T resource, boolean unknownState) { onEvent(ResourceAction.DELETED, resource, null, unknownState, true); } - /** - * @return true if the resourceVersion was obsolete - */ public EventHandling onAddOrUpdateEvent( ResourceAction action, T resource, T prevResourceVersion) { return onEvent(action, resource, prevResourceVersion, false, false); @@ -127,19 +124,21 @@ private synchronized EventHandling onEvent( var resourceId = ResourceID.fromResource(resource); if (log.isDebugEnabled()) { - log.debug( - "Processing event for resource id: {} version: {} ", - resourceId, - resource.getMetadata().getResourceVersion()); + log.debug("Processing event"); } if (!unknownState) { latestResourceVersion = resource.getMetadata().getResourceVersion(); + log.debug("Setting latest resource version to: {}", latestResourceVersion); } var cached = cache.get(resourceId); EventHandling result = EventHandling.NEW; if (cached != null) { int comp = ReconcilerUtilsInternal.compareResourceVersions(resource, cached); if (comp >= 0 || unknownState) { + log.debug( + "Removing resource from temp cache. comparison: {} unknown state: {}", + comp, + unknownState); cache.remove(resourceId); // we propagate event only for our update or newer other can be discarded since we know we // will receive @@ -151,6 +150,7 @@ private synchronized EventHandling onEvent( } var ed = activeUpdates.get(resourceId); if (ed != null && result != EventHandling.OBSOLETE) { + log.debug("Setting last event for id: {} delete: {}", resourceId, delete); ed.setLastEvent( delete ? new ResourceDeleteEvent(ResourceAction.DELETED, resourceId, resource, unknownState) diff --git a/operator-framework-core/src/test/resources/log4j2.xml b/operator-framework-core/src/test/resources/log4j2.xml index be03b531ac..6c2aa05616 100644 --- a/operator-framework-core/src/test/resources/log4j2.xml +++ b/operator-framework-core/src/test/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/operator-framework/src/test/resources/log4j2.xml b/operator-framework/src/test/resources/log4j2.xml index e922079cc8..3a6e259e31 100644 --- a/operator-framework/src/test/resources/log4j2.xml +++ b/operator-framework/src/test/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml b/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml index bb61366dcf..147f494c1d 100644 --- a/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml +++ b/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/sample-operators/leader-election/src/main/resources/log4j2.xml b/sample-operators/leader-election/src/main/resources/log4j2.xml index bb61366dcf..147f494c1d 100644 --- a/sample-operators/leader-election/src/main/resources/log4j2.xml +++ b/sample-operators/leader-election/src/main/resources/log4j2.xml @@ -19,7 +19,7 @@ - + diff --git a/sample-operators/mysql-schema/src/main/resources/log4j2.xml b/sample-operators/mysql-schema/src/main/resources/log4j2.xml index 054261c13f..2979258355 100644 --- a/sample-operators/mysql-schema/src/main/resources/log4j2.xml +++ b/sample-operators/mysql-schema/src/main/resources/log4j2.xml @@ -19,11 +19,11 @@ - + - + diff --git a/sample-operators/tomcat-operator/src/main/resources/log4j2.xml b/sample-operators/tomcat-operator/src/main/resources/log4j2.xml index 21b0ee5480..147f494c1d 100644 --- a/sample-operators/tomcat-operator/src/main/resources/log4j2.xml +++ b/sample-operators/tomcat-operator/src/main/resources/log4j2.xml @@ -19,11 +19,11 @@ - + - + diff --git a/sample-operators/webpage/src/main/resources/log4j2.xml b/sample-operators/webpage/src/main/resources/log4j2.xml index 0bf270c7e6..2979258355 100644 --- a/sample-operators/webpage/src/main/resources/log4j2.xml +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -19,7 +19,7 @@ - + From 22262396297d77c749ba9b464f80e851e1e1875a Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Wed, 25 Feb 2026 15:49:16 +0100 Subject: [PATCH 26/58] fix: unify how resource information is added, prevent NPEs (#3185) * fix: unify how resource information is added, prevent NPEs Fixes #3183 Some (all?) MDC implementations prevent adding null values so default should be provided or the key omitted. Signed-off-by: Chris Laprun * fix: prefix name Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: cosmetic Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: remove unused methods Signed-off-by: Chris Laprun --------- Signed-off-by: Chris Laprun Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../operator/processing/MDCUtils.java | 86 +++++++------------ 1 file changed, 32 insertions(+), 54 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java index 68043be070..b8a7ba1f40 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java @@ -35,44 +35,28 @@ public class MDCUtils { private static final boolean enabled = Utils.getBooleanFromSystemPropsOrDefault(Utils.USE_MDC_ENV_KEY, true); - private static final String EVENT_RESOURCE_NAME = "eventsource.event.resource.name"; - private static final String EVENT_RESOURCE_UID = "eventsource.event.resource.uid"; - private static final String EVENT_RESOURCE_NAMESPACE = "eventsource.event.resource.namespace"; - private static final String EVENT_RESOURCE_KIND = "eventsource.event.resource.kind"; - private static final String EVENT_RESOURCE_VERSION = "eventsource.event.resource.resourceVersion"; - private static final String EVENT_ACTION = "eventsource.event.action"; + private static final String EVENT_SOURCE_PREFIX = "eventsource.event."; + private static final String EVENT_ACTION = EVENT_SOURCE_PREFIX + "action"; private static final String EVENT_SOURCE_NAME = "eventsource.name"; + private static final String UNKNOWN_ACTION = "unknown action"; public static void addInformerEventInfo( HasMetadata resource, ResourceAction action, String eventSourceName) { if (enabled) { - MDC.put(EVENT_RESOURCE_NAME, resource.getMetadata().getName()); - MDC.put(EVENT_RESOURCE_NAMESPACE, resource.getMetadata().getNamespace()); - MDC.put(EVENT_RESOURCE_KIND, HasMetadata.getKind(resource.getClass())); - MDC.put(EVENT_RESOURCE_VERSION, resource.getMetadata().getResourceVersion()); - MDC.put(EVENT_RESOURCE_UID, resource.getMetadata().getUid()); - MDC.put(EVENT_ACTION, action == null ? null : action.name()); + addResourceInfo(resource, true); + MDC.put(EVENT_ACTION, action == null ? UNKNOWN_ACTION : action.name()); MDC.put(EVENT_SOURCE_NAME, eventSourceName); } } public static void removeInformerEventInfo() { if (enabled) { - MDC.remove(EVENT_RESOURCE_NAME); - MDC.remove(EVENT_RESOURCE_NAMESPACE); - MDC.remove(EVENT_RESOURCE_KIND); - MDC.remove(EVENT_RESOURCE_VERSION); - MDC.remove(EVENT_RESOURCE_UID); + removeResourceInfo(true); MDC.remove(EVENT_ACTION); MDC.remove(EVENT_SOURCE_NAME); } } - public static void withMDCForEvent( - HasMetadata resource, Runnable runnable, String eventSourceName) { - withMDCForEvent(resource, null, runnable, eventSourceName); - } - public static void withMDCForEvent( HasMetadata resource, ResourceAction action, Runnable runnable, String eventSourceName) { try { @@ -83,24 +67,6 @@ public static void withMDCForEvent( } } - public static void withMDCForResourceID(ResourceID resourceID, Runnable runnable) { - try { - MDCUtils.addResourceIDInfo(resourceID); - runnable.run(); - } finally { - MDCUtils.removeResourceIDInfo(); - } - } - - public static void withMDCForPrimary(HasMetadata primary, Runnable runnable) { - try { - MDCUtils.addResourceInfo(primary); - runnable.run(); - } finally { - MDCUtils.removeResourceInfo(); - } - } - public static void addResourceIDInfo(ResourceID resourceID) { if (enabled) { MDC.put(NAME, resourceID.getName()); @@ -116,33 +82,45 @@ public static void removeResourceIDInfo() { } public static void addResourceInfo(HasMetadata resource) { + addResourceInfo(resource, false); + } + + public static void addResourceInfo(HasMetadata resource, boolean forEventSource) { if (enabled) { - MDC.put(API_VERSION, resource.getApiVersion()); - MDC.put(KIND, resource.getKind()); + MDC.put(key(API_VERSION, forEventSource), resource.getApiVersion()); + MDC.put(key(KIND, forEventSource), resource.getKind()); final var metadata = resource.getMetadata(); if (metadata != null) { - MDC.put(NAME, metadata.getName()); + MDC.put(key(NAME, forEventSource), metadata.getName()); if (metadata.getNamespace() != null) { - MDC.put(NAMESPACE, metadata.getNamespace()); + MDC.put(key(NAMESPACE, forEventSource), metadata.getNamespace()); } - MDC.put(RESOURCE_VERSION, metadata.getResourceVersion()); + MDC.put(key(RESOURCE_VERSION, forEventSource), metadata.getResourceVersion()); if (metadata.getGeneration() != null) { - MDC.put(GENERATION, metadata.getGeneration().toString()); + MDC.put(key(GENERATION, forEventSource), metadata.getGeneration().toString()); } - MDC.put(UID, metadata.getUid()); + MDC.put(key(UID, forEventSource), metadata.getUid()); } } } + private static String key(String baseKey, boolean forEventSource) { + return forEventSource ? EVENT_SOURCE_PREFIX + baseKey : baseKey; + } + public static void removeResourceInfo() { + removeResourceInfo(false); + } + + public static void removeResourceInfo(boolean forEventSource) { if (enabled) { - MDC.remove(API_VERSION); - MDC.remove(KIND); - MDC.remove(NAME); - MDC.remove(NAMESPACE); - MDC.remove(RESOURCE_VERSION); - MDC.remove(GENERATION); - MDC.remove(UID); + MDC.remove(key(API_VERSION, forEventSource)); + MDC.remove(key(KIND, forEventSource)); + MDC.remove(key(NAME, forEventSource)); + MDC.remove(key(NAMESPACE, forEventSource)); + MDC.remove(key(RESOURCE_VERSION, forEventSource)); + MDC.remove(key(GENERATION, forEventSource)); + MDC.remove(key(UID, forEventSource)); } } } From 8144ddf4096babc88179c145d276047a7f5dc326 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 26 Feb 2026 11:58:29 +0100 Subject: [PATCH 27/58] feat: emit MDCUtils.NO_NAMESPACE value when namespace is null (#3186) * feat: emit MDCUtils.NO_NAMESPACE value when namespace is null Fixes #3184 Signed-off-by: Chris Laprun * fix: improve wording Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: Chris Laprun Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/content/en/docs/documentation/observability.md | 7 +++++++ .../operator/processing/MDCUtils.java | 12 ++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/content/en/docs/documentation/observability.md b/docs/content/en/docs/documentation/observability.md index acadaea182..0a629ab0e7 100644 --- a/docs/content/en/docs/documentation/observability.md +++ b/docs/content/en/docs/documentation/observability.md @@ -50,6 +50,13 @@ For `InformerEventSource` and `ControllerEventSource` the following information | `eventsource.event.action` | action name (e.g. `ADDED`, `UPDATED`, `DELETED`) | | `eventsource.name` | name of the event source | +### Note on null values + +If a resource doesn't provide values for one of the specified keys, the key will be omitted and not added to the MDC +context. There is, however, one notable exception: the resource's namespace, where, instead of omitting the key, we emit +the `MDCUtils.NO_NAMESPACE` value instead. This allows searching for resources without namespace (notably, clustered +resources) in the logs more easily. + ### Disabling MDC support MDC support is enabled by default. If you want to disable it, you can set the `JAVA_OPERATOR_SDK_USE_MDC` environment diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java index b8a7ba1f40..716490388d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java @@ -23,6 +23,7 @@ import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; public class MDCUtils { + public static final String NO_NAMESPACE = "no namespace"; private static final String NAME = "resource.name"; private static final String NAMESPACE = "resource.namespace"; @@ -31,20 +32,18 @@ public class MDCUtils { private static final String RESOURCE_VERSION = "resource.resourceVersion"; private static final String GENERATION = "resource.generation"; private static final String UID = "resource.uid"; - private static final String NO_NAMESPACE = "no namespace"; private static final boolean enabled = Utils.getBooleanFromSystemPropsOrDefault(Utils.USE_MDC_ENV_KEY, true); private static final String EVENT_SOURCE_PREFIX = "eventsource.event."; private static final String EVENT_ACTION = EVENT_SOURCE_PREFIX + "action"; private static final String EVENT_SOURCE_NAME = "eventsource.name"; - private static final String UNKNOWN_ACTION = "unknown action"; public static void addInformerEventInfo( HasMetadata resource, ResourceAction action, String eventSourceName) { if (enabled) { addResourceInfo(resource, true); - MDC.put(EVENT_ACTION, action == null ? UNKNOWN_ACTION : action.name()); + MDC.put(EVENT_ACTION, action.name()); MDC.put(EVENT_SOURCE_NAME, eventSourceName); } } @@ -92,9 +91,10 @@ public static void addResourceInfo(HasMetadata resource, boolean forEventSource) final var metadata = resource.getMetadata(); if (metadata != null) { MDC.put(key(NAME, forEventSource), metadata.getName()); - if (metadata.getNamespace() != null) { - MDC.put(key(NAMESPACE, forEventSource), metadata.getNamespace()); - } + + final var namespace = metadata.getNamespace(); + MDC.put(key(NAMESPACE, forEventSource), namespace != null ? namespace : NO_NAMESPACE); + MDC.put(key(RESOURCE_VERSION, forEventSource), metadata.getResourceVersion()); if (metadata.getGeneration() != null) { MDC.put(key(GENERATION, forEventSource), metadata.getGeneration().toString()); From 486861327727191e4e7bd9d0f85253dff0643047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 26 Feb 2026 17:10:46 +0100 Subject: [PATCH 28/58] improve: do not close infra client if same as client (#3187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Signed-off-by: Attila Mészáros Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../operator/junit/LocallyRunOperatorExtension.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java index c4ed4c9e66..cd26234054 100644 --- a/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java +++ b/operator-framework-junit/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -365,7 +365,11 @@ protected void after(ExtensionContext context) { iterator.remove(); } - kubernetesClient.close(); + // if the client is used for infra client, we should not close it + // either test or operator should close this client + if (getKubernetesClient() != getInfrastructureKubernetesClient()) { + kubernetesClient.close(); + } try { this.operator.stop(); From 0afd4dac351f074899173343de501ee9db27240f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 28 Feb 2026 13:40:08 +0100 Subject: [PATCH 29/58] feat: add MDC to workflow execution (#3188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/MDCUtils.java | 9 ++++++ .../dependent/workflow/NodeExecutor.java | 30 +++++++++++-------- .../dependent/workflow/NodeExecutorTest.java | 5 ++-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java index 716490388d..e4931b6447 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java @@ -66,6 +66,15 @@ public static void withMDCForEvent( } } + public static void withMDCForResource(HasMetadata resource, Runnable runnable) { + try { + MDCUtils.addResourceInfo(resource); + runnable.run(); + } finally { + MDCUtils.removeResourceInfo(); + } + } + public static void addResourceIDInfo(ResourceID resourceID) { if (enabled) { MDC.put(NAME, resourceID.getName()); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java index 98756f7eb6..6da5d0f0ff 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java @@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.MDCUtils; abstract class NodeExecutor implements Runnable { @@ -36,19 +37,22 @@ protected NodeExecutor( @Override public void run() { - try { - doRun(dependentResourceNode); - - } catch (Exception e) { - // Exception is required because of Kotlin - workflowExecutor.handleExceptionInExecutor(dependentResourceNode, e); - } catch (Error e) { - // without this user would see no sign about the error - log.error("java.lang.Error during execution", e); - throw e; - } finally { - workflowExecutor.handleNodeExecutionFinish(dependentResourceNode); - } + MDCUtils.withMDCForResource( + workflowExecutor.primary, + () -> { + try { + doRun(dependentResourceNode); + } catch (Exception e) { + // Exception is required because of Kotlin + workflowExecutor.handleExceptionInExecutor(dependentResourceNode, e); + } catch (Error e) { + // without this user would see no sign about the error + log.error("java.lang.Error during execution", e); + throw e; + } finally { + workflowExecutor.handleNodeExecutionFinish(dependentResourceNode); + } + }); } protected abstract void doRun(DependentResourceNode dependentResourceNode); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java index 870bae9c58..65bf258543 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutorTest.java @@ -21,11 +21,10 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - class NodeExecutorTest { - private NodeExecutor errorThrowingNodeExecutor = + @SuppressWarnings({"rawtypes", "unchecked"}) + private final NodeExecutor errorThrowingNodeExecutor = new NodeExecutor(null, null) { @Override protected void doRun(DependentResourceNode dependentResourceNode) { From b819b31c9b0efdf2d4fe301597827ede46c60e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 22 Feb 2026 19:10:24 +0100 Subject: [PATCH 30/58] feat: configuration adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigLoader.java | 19 +++++++++++++++++++ .../api/config/loader/ConfigProvider.java | 8 ++++++++ .../config/loader/DefatulConfigProvider.java | 3 +++ 3 files changed, 30 insertions(+) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java new file mode 100644 index 0000000000..122b701b80 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.api.config.loader; + +import java.util.function.Consumer; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; + +public class ConfigLoader { + + Consumer operatorConfigs() { + return null; + } + + Consumer> controllerConfigs( + String controllerName) { + return null; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java new file mode 100644 index 0000000000..e486c45311 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java @@ -0,0 +1,8 @@ +package io.javaoperatorsdk.operator.api.config.loader; + +import java.util.Optional; + +public interface ConfigProvider { + + Optional getConfig(String key, Class type); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java new file mode 100644 index 0000000000..fc97393e5e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java @@ -0,0 +1,3 @@ +package io.javaoperatorsdk.operator.api.config.loader; + +public class DefatulConfigProvider implements ConfigProvider {} From 66bd1bfa8ff55c9bcbf14147c12a783be1622fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 22 Feb 2026 19:51:18 +0100 Subject: [PATCH 31/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/config/loader/ConfigLoader.java | 11 +++++++++++ .../operator/api/config/loader/ConfigProvider.java | 2 +- .../api/config/loader/DefatulConfigProvider.java | 10 +++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index 122b701b80..151b6e8480 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -8,7 +8,18 @@ public class ConfigLoader { + private ConfigProvider configProvider; + + public ConfigLoader() { + this(new DefatulConfigProvider()); + } + + public ConfigLoader(ConfigProvider configProvider) { + this.configProvider = configProvider; + } + Consumer operatorConfigs() { + return null; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java index e486c45311..d9c6828651 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java @@ -4,5 +4,5 @@ public interface ConfigProvider { - Optional getConfig(String key, Class type); + Optional getValue(String key, Class type); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java index fc97393e5e..475b7c8c1b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java @@ -1,3 +1,11 @@ package io.javaoperatorsdk.operator.api.config.loader; -public class DefatulConfigProvider implements ConfigProvider {} +import java.util.Optional; + +public class DefatulConfigProvider implements ConfigProvider { + @Override + public Optional getValue(String key, Class type) { + + return Optional.empty(); + } +} From e608b6872d4a9fd9ca7b52aed912ec7f7d2c27da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 22 Feb 2026 19:52:12 +0100 Subject: [PATCH 32/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/config/loader/ConfigLoader.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index 151b6e8480..cdebbf6452 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -18,13 +18,12 @@ public ConfigLoader(ConfigProvider configProvider) { this.configProvider = configProvider; } - Consumer operatorConfigs() { - + public Consumer applyConfigs() { return null; } - Consumer> controllerConfigs( - String controllerName) { + public + Consumer> applyControllerConfigs(String controllerName) { return null; } } From 8bda6e6466913e3e17e7073b89370ce686140842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 22 Feb 2026 20:40:39 +0100 Subject: [PATCH 33/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigBinding.java | 50 +++++ .../api/config/loader/ConfigLoader.java | 175 ++++++++++++++- .../api/config/loader/ConfigProvider.java | 25 +++ .../config/loader/DefatulConfigProvider.java | 11 - .../config/loader/DefaultConfigProvider.java | 62 ++++++ .../api/config/loader/ConfigBindingTest.java | 39 ++++ .../api/config/loader/ConfigLoaderTest.java | 207 ++++++++++++++++++ .../loader/DefaultConfigProviderTest.java | 98 +++++++++ 8 files changed, 652 insertions(+), 15 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBinding.java delete mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBindingTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBinding.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBinding.java new file mode 100644 index 0000000000..069932b189 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBinding.java @@ -0,0 +1,50 @@ +/* + * 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.config.loader; + +import java.util.function.BiConsumer; + +/** + * Associates a configuration key and its expected type with the setter that should be called on an + * overrider when the {@link ConfigProvider} returns a value for that key. + * + * @param the overrider type (e.g. {@code ConfigurationServiceOverrider}) + * @param the value type expected for this key + */ +public class ConfigBinding { + + private final String key; + private final Class type; + private final BiConsumer setter; + + public ConfigBinding(String key, Class type, BiConsumer setter) { + this.key = key; + this.type = type; + this.setter = setter; + } + + public String key() { + return key; + } + + public Class type() { + return type; + } + + public BiConsumer setter() { + return setter; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index cdebbf6452..58722cde17 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -1,5 +1,22 @@ +/* + * 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.config.loader; +import java.time.Duration; +import java.util.List; import java.util.function.Consumer; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -8,22 +25,172 @@ public class ConfigLoader { - private ConfigProvider configProvider; + public static final ConfigLoader DEFAULT = new ConfigLoader(); + + /** + * Key prefix for operator-level (ConfigurationService) properties, e.g. {@code + * josdk.concurrent.reconciliation.threads}. + */ + public static final String OPERATOR_KEY_PREFIX = "josdk."; + + /** + * Key prefix for controller-level properties. The controller name is inserted between this prefix + * and the property name, e.g. {@code josdk.controller.my-controller.finalizer}. + */ + public static final String CONTROLLER_KEY_PREFIX = "josdk.controller."; + + // --------------------------------------------------------------------------- + // Operator-level (ConfigurationServiceOverrider) bindings + // Only scalar / value types that a key-value ConfigProvider can supply are + // included. Complex objects (KubernetesClient, ExecutorService, …) must be + // configured programmatically and are intentionally omitted. + // --------------------------------------------------------------------------- + private static final List> OPERATOR_BINDINGS = + List.of( + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "check.crd.and.validate.local.model", + Boolean.class, + ConfigurationServiceOverrider::checkingCRDAndValidateLocalModel), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "concurrent.reconciliation.threads", + Integer.class, + ConfigurationServiceOverrider::withConcurrentReconciliationThreads), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "concurrent.workflow.executor.threads", + Integer.class, + ConfigurationServiceOverrider::withConcurrentWorkflowExecutorThreads), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "close.client.on.stop", + Boolean.class, + ConfigurationServiceOverrider::withCloseClientOnStop), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "stop.on.informer.error.during.startup", + Boolean.class, + ConfigurationServiceOverrider::withStopOnInformerErrorDuringStartup), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "cache.sync.timeout", + Duration.class, + ConfigurationServiceOverrider::withCacheSyncTimeout), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "reconciliation.termination.timeout", + Duration.class, + ConfigurationServiceOverrider::withReconciliationTerminationTimeout), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "ssa.based.create.update.match.for.dependent.resources", + Boolean.class, + ConfigurationServiceOverrider::withSSABasedCreateUpdateMatchForDependentResources), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "use.ssa.to.patch.primary.resource", + Boolean.class, + ConfigurationServiceOverrider::withUseSSAToPatchPrimaryResource), + new ConfigBinding<>( + OPERATOR_KEY_PREFIX + "clone.secondary.resources.when.getting.from.cache", + Boolean.class, + ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache)); + + // --------------------------------------------------------------------------- + // Controller-level (ControllerConfigurationOverrider) bindings + // The key used at runtime is built as: + // CONTROLLER_KEY_PREFIX + controllerName + "." + + // --------------------------------------------------------------------------- + private static final List, ?>> + CONTROLLER_BINDINGS = + List.of( + new ConfigBinding<>( + "finalizer", String.class, ControllerConfigurationOverrider::withFinalizer), + new ConfigBinding<>( + "generation.aware", + Boolean.class, + ControllerConfigurationOverrider::withGenerationAware), + new ConfigBinding<>( + "label.selector", + String.class, + ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "reconciliation.max.interval", + Duration.class, + ControllerConfigurationOverrider::withReconciliationMaxInterval), + new ConfigBinding<>( + "field.manager", + String.class, + ControllerConfigurationOverrider::withFieldManager), + new ConfigBinding<>( + "trigger.reconciler.on.all.events", + Boolean.class, + ControllerConfigurationOverrider::withTriggerReconcilerOnAllEvents), + new ConfigBinding<>( + "informer.list.limit", + Long.class, + ControllerConfigurationOverrider::withInformerListLimit)); + + private final ConfigProvider configProvider; public ConfigLoader() { - this(new DefatulConfigProvider()); + this(new DefaultConfigProvider()); } public ConfigLoader(ConfigProvider configProvider) { this.configProvider = configProvider; } + /** + * Returns a {@link Consumer} that applies every operator-level property found in the {@link + * ConfigProvider} to the given {@link ConfigurationServiceOverrider}. Returns {@code null} when + * no binding has a matching value, preserving the previous behaviour. + */ public Consumer applyConfigs() { - return null; + return buildConsumer(OPERATOR_BINDINGS, null); } + /** + * Returns a {@link Consumer} that applies every controller-level property found in the {@link + * ConfigProvider} to the given {@link ControllerConfigurationOverrider}. The keys are looked up + * as {@code josdk.controller..}. Returns {@code null} when no binding + * has a matching value. + */ + @SuppressWarnings("unchecked") public Consumer> applyControllerConfigs(String controllerName) { - return null; + String prefix = CONTROLLER_KEY_PREFIX + controllerName + "."; + // Cast is safe: the setter BiConsumer, T> is covariant in + // its first parameter for our usage – we only ever call it with + // ControllerConfigurationOverrider. + List, ?>> bindings = + (List, ?>>) (List) CONTROLLER_BINDINGS; + return buildConsumer(bindings, prefix); + } + + /** + * Iterates {@code bindings} and, for each one whose key (optionally prefixed by {@code + * keyPrefix}) is present in the {@link ConfigProvider}, accumulates a call to the binding's + * setter. + * + * @param bindings the predefined bindings to check + * @param keyPrefix when non-null the key stored in the binding is treated as a suffix and this + * prefix is prepended before the lookup + * @return a consumer that applies all found values, or {@code null} if none were found + */ + private Consumer buildConsumer(List> bindings, String keyPrefix) { + Consumer consumer = null; + for (var binding : bindings) { + String lookupKey = keyPrefix == null ? binding.key() : keyPrefix + binding.key(); + Consumer step = resolveStep(binding, lookupKey); + if (step != null) { + consumer = consumer == null ? step : consumer.andThen(step); + } + } + return consumer; + } + + /** + * Queries the {@link ConfigProvider} for {@code key} with the binding's type. If a value is + * present, returns a {@link Consumer} that calls the binding's setter; otherwise returns {@code + * null}. + */ + private Consumer resolveStep(ConfigBinding binding, String key) { + return configProvider + .getValue(key, binding.type()) + .map(value -> (Consumer) overrider -> binding.setter().accept(overrider, value)) + .orElse(null); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java index d9c6828651..9279439d68 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java @@ -1,8 +1,33 @@ +/* + * 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.config.loader; import java.util.Optional; public interface ConfigProvider { + /** + * Returns the value associated with {@code key}, converted to {@code type}, or an empty {@link + * Optional} if the key is not set. + * + * @param key the dot-separated configuration key, e.g. {@code josdk.cache.sync.timeout} + * @param type the expected type of the value; supported types depend on the implementation + * @param the value type + * @return an {@link Optional} containing the typed value, or empty if the key is absent + * @throws IllegalArgumentException if {@code type} is not supported by the implementation + */ Optional getValue(String key, Class type); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java deleted file mode 100644 index 475b7c8c1b..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefatulConfigProvider.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.javaoperatorsdk.operator.api.config.loader; - -import java.util.Optional; - -public class DefatulConfigProvider implements ConfigProvider { - @Override - public Optional getValue(String key, Class type) { - - return Optional.empty(); - } -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java new file mode 100644 index 0000000000..df0f41792c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java @@ -0,0 +1,62 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.api.config.loader; + +import java.time.Duration; +import java.util.Optional; + +public class DefaultConfigProvider implements ConfigProvider { + + /** + * Looks up {@code key} first as an environment variable (dots and hyphens replaced by + * underscores, uppercased, e.g. {@code josdk.cache.sync.timeout} → {@code + * JOSDK_CACHE_SYNC_TIMEOUT}), then as a system property with the key as-is. The environment + * variable takes precedence when both are set. + */ + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + String raw = resolveRaw(key); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(type.cast(convert(raw, type))); + } + + private String resolveRaw(String key) { + String envKey = key.replace('.', '_').replace('-', '_').toUpperCase(); + String envValue = System.getenv(envKey); + if (envValue != null) { + return envValue; + } + return System.getProperty(key); + } + + private Object convert(String raw, Class type) { + if (type == String.class) { + return raw; + } else if (type == Boolean.class) { + return Boolean.parseBoolean(raw); + } else if (type == Integer.class) { + return Integer.parseInt(raw); + } else if (type == Long.class) { + return Long.parseLong(raw); + } else if (type == Duration.class) { + return Duration.parse(raw); + } + throw new IllegalArgumentException("Unsupported config type: " + type.getName()); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBindingTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBindingTest.java new file mode 100644 index 0000000000..6a1c7aeecd --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBindingTest.java @@ -0,0 +1,39 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.api.config.loader; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfigBindingTest { + + @Test + void storesKeyTypeAndSetter() { + List calls = new ArrayList<>(); + ConfigBinding, String> binding = + new ConfigBinding<>("my.key", String.class, (list, v) -> list.add(v)); + + assertThat(binding.key()).isEqualTo("my.key"); + assertThat(binding.type()).isEqualTo(String.class); + + binding.setter().accept(calls, "hello"); + assertThat(calls).containsExactly("hello"); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java new file mode 100644 index 0000000000..6635f11962 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java @@ -0,0 +1,207 @@ +/* + * 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.config.loader; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfigLoaderTest { + + // A simple ConfigProvider backed by a plain map for test control. + private static ConfigProvider mapProvider(Map values) { + return new ConfigProvider() { + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + return Optional.ofNullable((T) values.get(key)); + } + }; + } + + // -- applyConfigs ----------------------------------------------------------- + + @Test + void applyConfigsReturnsNullWhenNothingConfigured() { + var loader = new ConfigLoader(mapProvider(Map.of())); + assertThat(loader.applyConfigs()).isNull(); + } + + @Test + void applyConfigsAppliesConcurrentReconciliationThreads() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.concurrent.reconciliation.threads", 7))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentReconciliationThreads()).isEqualTo(7); + } + + @Test + void applyConfigsAppliesConcurrentWorkflowExecutorThreads() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.concurrent.workflow.executor.threads", 3))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentWorkflowExecutorThreads()).isEqualTo(3); + } + + @Test + void applyConfigsAppliesBooleanFlags() { + var values = new HashMap(); + values.put("josdk.check.crd.and.validate.local.model", true); + values.put("josdk.close.client.on.stop", false); + values.put("josdk.stop.on.informer.error.during.startup", false); + values.put("josdk.ssa.based.create.update.match.for.dependent.resources", false); + values.put("josdk.use.ssa.to.patch.primary.resource", false); + values.put("josdk.clone.secondary.resources.when.getting.from.cache", true); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.checkCRDAndValidateLocalModel()).isTrue(); + assertThat(result.closeClientOnStop()).isFalse(); + assertThat(result.stopOnInformerErrorDuringStartup()).isFalse(); + assertThat(result.ssaBasedCreateUpdateMatchForDependentResources()).isFalse(); + assertThat(result.useSSAToPatchPrimaryResource()).isFalse(); + assertThat(result.cloneSecondaryResourcesWhenGettingFromCache()).isTrue(); + } + + @Test + void applyConfigsAppliesDurations() { + var values = new HashMap(); + values.put("josdk.cache.sync.timeout", Duration.ofSeconds(10)); + values.put("josdk.reconciliation.termination.timeout", Duration.ofSeconds(5)); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.cacheSyncTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(result.reconciliationTerminationTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void applyConfigsOnlyAppliesPresentKeys() { + // Only one key present — other defaults must be unchanged. + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.concurrent.reconciliation.threads", 12))); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.concurrentReconciliationThreads()).isEqualTo(12); + // Default unchanged + assertThat(result.concurrentWorkflowExecutorThreads()) + .isEqualTo(base.concurrentWorkflowExecutorThreads()); + } + + // -- applyControllerConfigs ------------------------------------------------- + + @Test + void applyControllerConfigsReturnsNullWhenNothingConfigured() { + var loader = new ConfigLoader(mapProvider(Map.of())); + assertThat(loader.applyControllerConfigs("my-controller")).isNull(); + } + + @Test + void applyControllerConfigsQueriesKeysPrefixedWithControllerName() { + // Record every key the loader asks for, regardless of whether a value exists. + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + + new ConfigLoader(recordingProvider).applyControllerConfigs("my-ctrl"); + + assertThat(queriedKeys).allMatch(k -> k.startsWith("josdk.controller.my-ctrl.")); + } + + @Test + void applyControllerConfigsIsolatesControllersByName() { + // Two controllers configured in the same provider — only matching keys must be returned. + var values = new HashMap(); + values.put("josdk.controller.alpha.finalizer", "alpha-finalizer"); + values.put("josdk.controller.beta.finalizer", "beta-finalizer"); + var loader = new ConfigLoader(mapProvider(values)); + + // alpha gets a consumer (key found), beta gets a consumer (key found) + assertThat(loader.applyControllerConfigs("alpha")).isNotNull(); + assertThat(loader.applyControllerConfigs("beta")).isNotNull(); + // a controller with no configured keys gets null + assertThat(loader.applyControllerConfigs("gamma")).isNull(); + } + + @Test + void applyControllerConfigsQueriesAllExpectedPropertySuffixes() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.finalizer", + "josdk.controller.ctrl.generation.aware", + "josdk.controller.ctrl.label.selector", + "josdk.controller.ctrl.reconciliation.max.interval", + "josdk.controller.ctrl.field.manager", + "josdk.controller.ctrl.trigger.reconciler.on.all.events", + "josdk.controller.ctrl.informer.list.limit"); + } + + // -- key prefix constants --------------------------------------------------- + + @Test + void operatorKeyPrefixIsJosdkDot() { + assertThat(ConfigLoader.OPERATOR_KEY_PREFIX).isEqualTo("josdk."); + } + + @Test + void controllerKeyPrefixIsJosdkControllerDot() { + assertThat(ConfigLoader.CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java new file mode 100644 index 0000000000..7ef413699e --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java @@ -0,0 +1,98 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.api.config.loader; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class DefaultConfigProviderTest { + + private final DefaultConfigProvider provider = new DefaultConfigProvider(); + + // -- system property tests -------------------------------------------------- + + @Test + void returnsEmptyWhenNeitherEnvNorPropertyIsSet() { + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void readsStringFromSystemProperty() { + System.setProperty("josdk.test.string", "hello"); + try { + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); + } finally { + System.clearProperty("josdk.test.string"); + } + } + + @Test + void readsBooleanFromSystemProperty() { + System.setProperty("josdk.test.bool", "true"); + try { + assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); + } finally { + System.clearProperty("josdk.test.bool"); + } + } + + @Test + void readsIntegerFromSystemProperty() { + System.setProperty("josdk.test.integer", "42"); + try { + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); + } finally { + System.clearProperty("josdk.test.integer"); + } + } + + @Test + void readsLongFromSystemProperty() { + System.setProperty("josdk.test.long", "123456789"); + try { + assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); + } finally { + System.clearProperty("josdk.test.long"); + } + } + + @Test + void readsDurationFromSystemProperty() { + System.setProperty("josdk.test.duration", "PT30S"); + try { + assertThat(provider.getValue("josdk.test.duration", Duration.class)) + .hasValue(Duration.ofSeconds(30)); + } finally { + System.clearProperty("josdk.test.duration"); + } + } + + @Test + void throwsForUnsupportedType() { + System.setProperty("josdk.test.unsupported", "value"); + try { + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", Double.class)) + .withMessageContaining("Unsupported config type"); + } finally { + System.clearProperty("josdk.test.unsupported"); + } + } +} From 2cae73eda85505b0711a90d17c2c6b03d41dea42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 15:40:24 +0100 Subject: [PATCH 34/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigLoader.java | 64 ++++++++++--------- .../config/loader/DefaultConfigProvider.java | 13 +++- .../api/config/loader/ConfigLoaderTest.java | 39 ++++++----- .../loader/DefaultConfigProviderTest.java | 40 +++++++++++- 4 files changed, 104 insertions(+), 52 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index 58722cde17..6c7811c863 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -27,17 +27,16 @@ public class ConfigLoader { public static final ConfigLoader DEFAULT = new ConfigLoader(); - /** - * Key prefix for operator-level (ConfigurationService) properties, e.g. {@code - * josdk.concurrent.reconciliation.threads}. - */ - public static final String OPERATOR_KEY_PREFIX = "josdk."; + public static final String DEFAULT_OPERATOR_KEY_PREFIX = "josdk."; + public static final String DEFAULT_CONTROLLER_KEY_PREFIX = "josdk.controller."; /** * Key prefix for controller-level properties. The controller name is inserted between this prefix * and the property name, e.g. {@code josdk.controller.my-controller.finalizer}. */ - public static final String CONTROLLER_KEY_PREFIX = "josdk.controller."; + private final String controllerKeyPrefix; + + private final String operatorKeyPrefix; // --------------------------------------------------------------------------- // Operator-level (ConfigurationServiceOverrider) bindings @@ -48,43 +47,43 @@ public class ConfigLoader { private static final List> OPERATOR_BINDINGS = List.of( new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "check.crd.and.validate.local.model", + "check-crd", Boolean.class, ConfigurationServiceOverrider::checkingCRDAndValidateLocalModel), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "concurrent.reconciliation.threads", + "reconciliation.termination-timeout", + Duration.class, + ConfigurationServiceOverrider::withReconciliationTerminationTimeout), + new ConfigBinding<>( + "reconciliation.concurrent-threads", Integer.class, ConfigurationServiceOverrider::withConcurrentReconciliationThreads), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "concurrent.workflow.executor.threads", + "workflow.executor-threads", Integer.class, ConfigurationServiceOverrider::withConcurrentWorkflowExecutorThreads), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "close.client.on.stop", + "close-client-on-stop", Boolean.class, ConfigurationServiceOverrider::withCloseClientOnStop), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "stop.on.informer.error.during.startup", + "informer.stop-on-error-during-startup", Boolean.class, ConfigurationServiceOverrider::withStopOnInformerErrorDuringStartup), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "cache.sync.timeout", + "informer.cache-sync-timeout", Duration.class, ConfigurationServiceOverrider::withCacheSyncTimeout), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "reconciliation.termination.timeout", - Duration.class, - ConfigurationServiceOverrider::withReconciliationTerminationTimeout), - new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "ssa.based.create.update.match.for.dependent.resources", + "dependent-resources.ssa-based-create-update-match", Boolean.class, ConfigurationServiceOverrider::withSSABasedCreateUpdateMatchForDependentResources), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "use.ssa.to.patch.primary.resource", + "use-ssa-to-patch-primary-resource", Boolean.class, ConfigurationServiceOverrider::withUseSSAToPatchPrimaryResource), new ConfigBinding<>( - OPERATOR_KEY_PREFIX + "clone.secondary.resources.when.getting.from.cache", + "clone-secondary-resources-when-getting-from-cache", Boolean.class, ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache)); @@ -99,47 +98,54 @@ public class ConfigLoader { new ConfigBinding<>( "finalizer", String.class, ControllerConfigurationOverrider::withFinalizer), new ConfigBinding<>( - "generation.aware", + "generation-aware", Boolean.class, ControllerConfigurationOverrider::withGenerationAware), new ConfigBinding<>( - "label.selector", + "label-selector", String.class, ControllerConfigurationOverrider::withLabelSelector), new ConfigBinding<>( - "reconciliation.max.interval", + "max-reconciliation-interval", Duration.class, ControllerConfigurationOverrider::withReconciliationMaxInterval), new ConfigBinding<>( - "field.manager", + "field-manager", String.class, ControllerConfigurationOverrider::withFieldManager), new ConfigBinding<>( - "trigger.reconciler.on.all.events", + "trigger-reconciler-on-all-events", Boolean.class, ControllerConfigurationOverrider::withTriggerReconcilerOnAllEvents), new ConfigBinding<>( - "informer.list.limit", + "informer-list-limit", Long.class, ControllerConfigurationOverrider::withInformerListLimit)); private final ConfigProvider configProvider; public ConfigLoader() { - this(new DefaultConfigProvider()); + this(new DefaultConfigProvider(), DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); } public ConfigLoader(ConfigProvider configProvider) { + this(configProvider, DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); + } + + public ConfigLoader( + ConfigProvider configProvider, String controllerKeyPrefix, String operatorKeyPrefix) { this.configProvider = configProvider; + this.controllerKeyPrefix = controllerKeyPrefix; + this.operatorKeyPrefix = operatorKeyPrefix; } /** * Returns a {@link Consumer} that applies every operator-level property found in the {@link * ConfigProvider} to the given {@link ConfigurationServiceOverrider}. Returns {@code null} when - * no binding has a matching value, preserving the previous behaviour. + * no binding has a matching value, preserving the previous behavior. */ public Consumer applyConfigs() { - return buildConsumer(OPERATOR_BINDINGS, null); + return buildConsumer(OPERATOR_BINDINGS, operatorKeyPrefix); } /** @@ -151,7 +157,7 @@ public Consumer applyConfigs() { @SuppressWarnings("unchecked") public Consumer> applyControllerConfigs(String controllerName) { - String prefix = CONTROLLER_KEY_PREFIX + controllerName + "."; + String prefix = controllerKeyPrefix + controllerName + "."; // Cast is safe: the setter BiConsumer, T> is covariant in // its first parameter for our usage – we only ever call it with // ControllerConfigurationOverrider. diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java index df0f41792c..a73ba5b6a5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java @@ -17,9 +17,20 @@ import java.time.Duration; import java.util.Optional; +import java.util.function.Function; public class DefaultConfigProvider implements ConfigProvider { + private final Function envLookup; + + public DefaultConfigProvider() { + this(System::getenv); + } + + DefaultConfigProvider(Function envLookup) { + this.envLookup = envLookup; + } + /** * Looks up {@code key} first as an environment variable (dots and hyphens replaced by * underscores, uppercased, e.g. {@code josdk.cache.sync.timeout} → {@code @@ -38,7 +49,7 @@ public Optional getValue(String key, Class type) { private String resolveRaw(String key) { String envKey = key.replace('.', '_').replace('-', '_').toUpperCase(); - String envValue = System.getenv(envKey); + String envValue = envLookup.apply(envKey); if (envValue != null) { return envValue; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java index 6635f11962..c31c27cafd 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java @@ -52,7 +52,7 @@ void applyConfigsReturnsNullWhenNothingConfigured() { @Test void applyConfigsAppliesConcurrentReconciliationThreads() { var loader = - new ConfigLoader(mapProvider(Map.of("josdk.concurrent.reconciliation.threads", 7))); + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 7))); var base = new BaseConfigurationService(null); var result = @@ -63,8 +63,7 @@ void applyConfigsAppliesConcurrentReconciliationThreads() { @Test void applyConfigsAppliesConcurrentWorkflowExecutorThreads() { - var loader = - new ConfigLoader(mapProvider(Map.of("josdk.concurrent.workflow.executor.threads", 3))); + var loader = new ConfigLoader(mapProvider(Map.of("josdk.workflow.executor-threads", 3))); var base = new BaseConfigurationService(null); var result = @@ -76,12 +75,12 @@ void applyConfigsAppliesConcurrentWorkflowExecutorThreads() { @Test void applyConfigsAppliesBooleanFlags() { var values = new HashMap(); - values.put("josdk.check.crd.and.validate.local.model", true); - values.put("josdk.close.client.on.stop", false); - values.put("josdk.stop.on.informer.error.during.startup", false); - values.put("josdk.ssa.based.create.update.match.for.dependent.resources", false); - values.put("josdk.use.ssa.to.patch.primary.resource", false); - values.put("josdk.clone.secondary.resources.when.getting.from.cache", true); + values.put("josdk.check-crd", true); + values.put("josdk.close-client-on-stop", false); + values.put("josdk.informer.stop-on-error-during-startup", false); + values.put("josdk.dependent-resources.ssa-based-create-update-match", false); + values.put("josdk.use-ssa-to-patch-primary-resource", false); + values.put("josdk.clone-secondary-resources-when-getting-from-cache", true); var loader = new ConfigLoader(mapProvider(values)); var base = new BaseConfigurationService(null); @@ -99,8 +98,8 @@ void applyConfigsAppliesBooleanFlags() { @Test void applyConfigsAppliesDurations() { var values = new HashMap(); - values.put("josdk.cache.sync.timeout", Duration.ofSeconds(10)); - values.put("josdk.reconciliation.termination.timeout", Duration.ofSeconds(5)); + values.put("josdk.informer.cache-sync-timeout", Duration.ofSeconds(10)); + values.put("josdk.reconciliation.termination-timeout", Duration.ofSeconds(5)); var loader = new ConfigLoader(mapProvider(values)); var base = new BaseConfigurationService(null); @@ -115,7 +114,7 @@ void applyConfigsAppliesDurations() { void applyConfigsOnlyAppliesPresentKeys() { // Only one key present — other defaults must be unchanged. var loader = - new ConfigLoader(mapProvider(Map.of("josdk.concurrent.reconciliation.threads", 12))); + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 12))); var base = new BaseConfigurationService(null); var result = @@ -185,23 +184,23 @@ public Optional getValue(String key, Class type) { assertThat(queriedKeys) .contains( "josdk.controller.ctrl.finalizer", - "josdk.controller.ctrl.generation.aware", - "josdk.controller.ctrl.label.selector", - "josdk.controller.ctrl.reconciliation.max.interval", - "josdk.controller.ctrl.field.manager", - "josdk.controller.ctrl.trigger.reconciler.on.all.events", - "josdk.controller.ctrl.informer.list.limit"); + "josdk.controller.ctrl.generation-aware", + "josdk.controller.ctrl.label-selector", + "josdk.controller.ctrl.max-reconciliation-interval", + "josdk.controller.ctrl.field-manager", + "josdk.controller.ctrl.trigger-reconciler-on-all-events", + "josdk.controller.ctrl.informer-list-limit"); } // -- key prefix constants --------------------------------------------------- @Test void operatorKeyPrefixIsJosdkDot() { - assertThat(ConfigLoader.OPERATOR_KEY_PREFIX).isEqualTo("josdk."); + assertThat(ConfigLoader.DEFAULT_OPERATOR_KEY_PREFIX).isEqualTo("josdk."); } @Test void controllerKeyPrefixIsJosdkControllerDot() { - assertThat(ConfigLoader.CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); + assertThat(ConfigLoader.DEFAULT_CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java index 7ef413699e..a042c7dff4 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java @@ -26,13 +26,49 @@ class DefaultConfigProviderTest { private final DefaultConfigProvider provider = new DefaultConfigProvider(); - // -- system property tests -------------------------------------------------- - @Test void returnsEmptyWhenNeitherEnvNorPropertyIsSet() { assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); } + // -- env variable tests ----------------------------------------------------- + + @Test + void readsStringFromEnvVariable() { + var envProvider = new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_STRING") ? "from-env" : null); + assertThat(envProvider.getValue("josdk.test.string", String.class)).hasValue("from-env"); + } + + @Test + void envVariableKeyUsesUppercaseWithUnderscores() { + // dots and hyphens both become underscores, key is uppercased + var envProvider = new DefaultConfigProvider(k -> k.equals("JOSDK_CACHE_SYNC_TIMEOUT") ? "PT10S" : null); + assertThat(envProvider.getValue("josdk.cache-sync.timeout", Duration.class)) + .hasValue(Duration.ofSeconds(10)); + } + + @Test + void envVariableTakesPrecedenceOverSystemProperty() { + System.setProperty("josdk.test.precedence", "from-sysprop"); + try { + var envProvider = new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_PRECEDENCE") ? "from-env" : null); + assertThat(envProvider.getValue("josdk.test.precedence", String.class)).hasValue("from-env"); + } finally { + System.clearProperty("josdk.test.precedence"); + } + } + + @Test + void fallsBackToSystemPropertyWhenEnvVariableAbsent() { + System.setProperty("josdk.test.fallback", "from-sysprop"); + try { + var envProvider = new DefaultConfigProvider(k -> null); + assertThat(envProvider.getValue("josdk.test.fallback", String.class)).hasValue("from-sysprop"); + } finally { + System.clearProperty("josdk.test.fallback"); + } + } + @Test void readsStringFromSystemProperty() { System.setProperty("josdk.test.string", "hello"); From 3ce65a419db3b9e27d71ba2b05d43ca0b6e6708e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 15:57:08 +0100 Subject: [PATCH 35/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigLoader.java | 52 ++++++++++++++++++- .../config/loader/DefaultConfigProvider.java | 2 + .../loader/DefaultConfigProviderTest.java | 15 ++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index 6c7811c863..679c096b99 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -17,11 +17,13 @@ import java.time.Duration; import java.util.List; +import java.util.Optional; import java.util.function.Consumer; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; public class ConfigLoader { @@ -87,6 +89,14 @@ public class ConfigLoader { Boolean.class, ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache)); + // --------------------------------------------------------------------------- + // Controller-level retry property suffixes + // --------------------------------------------------------------------------- + static final String RETRY_MAX_ATTEMPTS_SUFFIX = "retry.max-attempts"; + static final String RETRY_INITIAL_INTERVAL_SUFFIX = "retry.initial-interval"; + static final String RETRY_INTERVAL_MULTIPLIER_SUFFIX = "retry.interval-multiplier"; + static final String RETRY_MAX_INTERVAL_SUFFIX = "retry.max-interval"; + // --------------------------------------------------------------------------- // Controller-level (ControllerConfigurationOverrider) bindings // The key used at runtime is built as: @@ -163,7 +173,47 @@ Consumer> applyControllerConfigs(String cont // ControllerConfigurationOverrider. List, ?>> bindings = (List, ?>>) (List) CONTROLLER_BINDINGS; - return buildConsumer(bindings, prefix); + Consumer> consumer = buildConsumer(bindings, prefix); + + Consumer> retryStep = buildRetryConsumer(prefix); + if (retryStep != null) { + consumer = consumer == null ? retryStep : consumer.andThen(retryStep); + } + + return consumer; + } + + /** + * If at least one retry property is present for the given prefix, returns a {@link Consumer} that + * builds a {@link GenericRetry} starting from {@link GenericRetry#defaultLimitedExponentialRetry} + * and overrides only the properties that are explicitly set. + */ + private Consumer> buildRetryConsumer( + String prefix) { + Optional maxAttempts = + configProvider.getValue(prefix + RETRY_MAX_ATTEMPTS_SUFFIX, Integer.class); + Optional initialInterval = + configProvider.getValue(prefix + RETRY_INITIAL_INTERVAL_SUFFIX, Long.class); + Optional intervalMultiplier = + configProvider.getValue(prefix + RETRY_INTERVAL_MULTIPLIER_SUFFIX, Double.class); + Optional maxInterval = + configProvider.getValue(prefix + RETRY_MAX_INTERVAL_SUFFIX, Long.class); + + if (maxAttempts.isEmpty() + && initialInterval.isEmpty() + && intervalMultiplier.isEmpty() + && maxInterval.isEmpty()) { + return null; + } + + return overrider -> { + GenericRetry retry = GenericRetry.defaultLimitedExponentialRetry(); + maxAttempts.ifPresent(retry::setMaxAttempts); + initialInterval.ifPresent(retry::setInitialInterval); + intervalMultiplier.ifPresent(retry::setIntervalMultiplier); + maxInterval.ifPresent(retry::setMaxInterval); + overrider.withRetry(retry); + }; } /** diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java index a73ba5b6a5..fdd82774be 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java @@ -65,6 +65,8 @@ private Object convert(String raw, Class type) { return Integer.parseInt(raw); } else if (type == Long.class) { return Long.parseLong(raw); + } else if (type == Double.class) { + return Double.parseDouble(raw); } else if (type == Duration.class) { return Duration.parse(raw); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java index a042c7dff4..d8821ffee9 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.api.config.loader; import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; @@ -35,14 +36,16 @@ void returnsEmptyWhenNeitherEnvNorPropertyIsSet() { @Test void readsStringFromEnvVariable() { - var envProvider = new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_STRING") ? "from-env" : null); + var envProvider = + new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_STRING") ? "from-env" : null); assertThat(envProvider.getValue("josdk.test.string", String.class)).hasValue("from-env"); } @Test void envVariableKeyUsesUppercaseWithUnderscores() { // dots and hyphens both become underscores, key is uppercased - var envProvider = new DefaultConfigProvider(k -> k.equals("JOSDK_CACHE_SYNC_TIMEOUT") ? "PT10S" : null); + var envProvider = + new DefaultConfigProvider(k -> k.equals("JOSDK_CACHE_SYNC_TIMEOUT") ? "PT10S" : null); assertThat(envProvider.getValue("josdk.cache-sync.timeout", Duration.class)) .hasValue(Duration.ofSeconds(10)); } @@ -51,7 +54,8 @@ void envVariableKeyUsesUppercaseWithUnderscores() { void envVariableTakesPrecedenceOverSystemProperty() { System.setProperty("josdk.test.precedence", "from-sysprop"); try { - var envProvider = new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_PRECEDENCE") ? "from-env" : null); + var envProvider = + new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_PRECEDENCE") ? "from-env" : null); assertThat(envProvider.getValue("josdk.test.precedence", String.class)).hasValue("from-env"); } finally { System.clearProperty("josdk.test.precedence"); @@ -63,7 +67,8 @@ void fallsBackToSystemPropertyWhenEnvVariableAbsent() { System.setProperty("josdk.test.fallback", "from-sysprop"); try { var envProvider = new DefaultConfigProvider(k -> null); - assertThat(envProvider.getValue("josdk.test.fallback", String.class)).hasValue("from-sysprop"); + assertThat(envProvider.getValue("josdk.test.fallback", String.class)) + .hasValue("from-sysprop"); } finally { System.clearProperty("josdk.test.fallback"); } @@ -125,7 +130,7 @@ void throwsForUnsupportedType() { System.setProperty("josdk.test.unsupported", "value"); try { assertThatIllegalArgumentException() - .isThrownBy(() -> provider.getValue("josdk.test.unsupported", Double.class)) + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) .withMessageContaining("Unsupported config type"); } finally { System.clearProperty("josdk.test.unsupported"); From 78907fbfdc9b974117c77a9596464dcb3b84c08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 16:15:42 +0100 Subject: [PATCH 36/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/sample/TomcatOperator.java | 11 ++++++----- .../operator/sample/TomcatReconciler.java | 5 +++-- .../operator/sample/WebappReconciler.java | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index bceaae8363..59ce9fd7b8 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -17,6 +17,7 @@ import java.io.IOException; +import io.javaoperatorsdk.operator.api.config.loader.ConfigLoader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.takes.facets.fork.FkRegex; @@ -28,13 +29,13 @@ public class TomcatOperator { - private static final Logger log = LoggerFactory.getLogger(TomcatOperator.class); - public static void main(String[] args) throws IOException { - Operator operator = new Operator(); - operator.register(new TomcatReconciler()); - operator.register(new WebappReconciler(operator.getKubernetesClient())); + Operator operator = new Operator(ConfigLoader.DEFAULT.applyConfigs()); + operator.register(new TomcatReconciler(), + ConfigLoader.DEFAULT.applyControllerConfigs(TomcatReconciler.TOMCAT_CONTROLLER_NAME)); + operator.register(new WebappReconciler(operator.getKubernetesClient()), + ConfigLoader.DEFAULT.applyControllerConfigs(WebappReconciler.WEBAPP_CONTROLLER_NAME)); operator.start(); new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD.")), 8080).start(Exit.NEVER); diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java index d2fa9a021f..874db639f6 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java @@ -35,10 +35,11 @@ @Dependent(type = DeploymentDependentResource.class), @Dependent(type = ServiceDependentResource.class) }) -@ControllerConfiguration +@ControllerConfiguration(name = TomcatReconciler.TOMCAT_CONTROLLER_NAME) public class TomcatReconciler implements Reconciler { - private final Logger log = LoggerFactory.getLogger(getClass()); + public static final String TOMCAT_CONTROLLER_NAME = "tomcat"; + private final Logger log = LoggerFactory.getLogger(getClass()); @Override public UpdateControl reconcile(Tomcat tomcat, Context context) { diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java index 5d362113ba..e5b1db0505 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java @@ -47,10 +47,11 @@ import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; -@ControllerConfiguration +@ControllerConfiguration(name = WebappReconciler.WEBAPP_CONTROLLER_NAME) public class WebappReconciler implements Reconciler, Cleaner { private static final Logger log = LoggerFactory.getLogger(WebappReconciler.class); + public static final String WEBAPP_CONTROLLER_NAME = "webapp"; private final KubernetesClient kubernetesClient; From 10f8c430a0e11b5a3adb98e93767d094d15c5a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 17:18:05 +0100 Subject: [PATCH 37/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/sample/TomcatOperator.java | 14 +++++++------- .../operator/sample/TomcatReconciler.java | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index 59ce9fd7b8..db3547cb05 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -17,25 +17,25 @@ import java.io.IOException; -import io.javaoperatorsdk.operator.api.config.loader.ConfigLoader; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.takes.facets.fork.FkRegex; import org.takes.facets.fork.TkFork; import org.takes.http.Exit; import org.takes.http.FtBasic; import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.config.loader.ConfigLoader; public class TomcatOperator { public static void main(String[] args) throws IOException { Operator operator = new Operator(ConfigLoader.DEFAULT.applyConfigs()); - operator.register(new TomcatReconciler(), - ConfigLoader.DEFAULT.applyControllerConfigs(TomcatReconciler.TOMCAT_CONTROLLER_NAME)); - operator.register(new WebappReconciler(operator.getKubernetesClient()), - ConfigLoader.DEFAULT.applyControllerConfigs(WebappReconciler.WEBAPP_CONTROLLER_NAME)); + operator.register( + new TomcatReconciler(), + ConfigLoader.DEFAULT.applyControllerConfigs(TomcatReconciler.TOMCAT_CONTROLLER_NAME)); + operator.register( + new WebappReconciler(operator.getKubernetesClient()), + ConfigLoader.DEFAULT.applyControllerConfigs(WebappReconciler.WEBAPP_CONTROLLER_NAME)); operator.start(); new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD.")), 8080).start(Exit.NEVER); diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java index 874db639f6..60a0d0bdc2 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java @@ -38,8 +38,8 @@ @ControllerConfiguration(name = TomcatReconciler.TOMCAT_CONTROLLER_NAME) public class TomcatReconciler implements Reconciler { - public static final String TOMCAT_CONTROLLER_NAME = "tomcat"; - private final Logger log = LoggerFactory.getLogger(getClass()); + public static final String TOMCAT_CONTROLLER_NAME = "tomcat"; + private final Logger log = LoggerFactory.getLogger(getClass()); @Override public UpdateControl reconcile(Tomcat tomcat, Context context) { From b42a4f4fd8fddd09f23549133b10e34d83e78c62 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Mon, 23 Feb 2026 19:49:15 +0100 Subject: [PATCH 38/58] refactor: improve conversion code Signed-off-by: Chris Laprun --- .../config/loader/DefaultConfigProvider.java | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java index fdd82774be..1c2c7f126f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java @@ -38,17 +38,19 @@ public DefaultConfigProvider() { * variable takes precedence when both are set. */ @Override - @SuppressWarnings("unchecked") public Optional getValue(String key, Class type) { String raw = resolveRaw(key); if (raw == null) { return Optional.empty(); } - return Optional.of(type.cast(convert(raw, type))); + return Optional.of(convert(raw, type)); } private String resolveRaw(String key) { - String envKey = key.replace('.', '_').replace('-', '_').toUpperCase(); + if (key == null) { + return null; + } + String envKey = toEnvKey(key); String envValue = envLookup.apply(envKey); if (envValue != null) { return envValue; @@ -56,20 +58,27 @@ private String resolveRaw(String key) { return System.getProperty(key); } - private Object convert(String raw, Class type) { + private static String toEnvKey(String key) { + return key.trim().replace('.', '_').replace('-', '_').toUpperCase(); + } + + private static T convert(String raw, Class type) { + final Object converted; if (type == String.class) { - return raw; + converted = raw; } else if (type == Boolean.class) { - return Boolean.parseBoolean(raw); + converted = Boolean.parseBoolean(raw); } else if (type == Integer.class) { - return Integer.parseInt(raw); + converted = Integer.parseInt(raw); } else if (type == Long.class) { - return Long.parseLong(raw); + converted = Long.parseLong(raw); } else if (type == Double.class) { - return Double.parseDouble(raw); + converted = Double.parseDouble(raw); } else if (type == Duration.class) { - return Duration.parse(raw); + converted = Duration.parse(raw); + } else { + throw new IllegalArgumentException("Unsupported config type: " + type.getName()); } - throw new IllegalArgumentException("Unsupported config type: " + type.getName()); + return type.cast(converted); } } From aca713264497213c6fe677f4849c1082f276a8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 20:40:28 +0100 Subject: [PATCH 39/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigLoader.java | 9 ++++++--- .../api/config/loader/ConfigLoaderTest.java | 20 +++++++++++++------ .../operator/sample/TomcatOperator.java | 8 ++++---- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index 679c096b99..eebb433fb2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -27,7 +27,11 @@ public class ConfigLoader { - public static final ConfigLoader DEFAULT = new ConfigLoader(); + private static final ConfigLoader DEFAULT = new ConfigLoader(); + + public static ConfigLoader getDefault() { + return DEFAULT; + } public static final String DEFAULT_OPERATOR_KEY_PREFIX = "josdk."; public static final String DEFAULT_CONTROLLER_KEY_PREFIX = "josdk.controller."; @@ -179,7 +183,6 @@ Consumer> applyControllerConfigs(String cont if (retryStep != null) { consumer = consumer == null ? retryStep : consumer.andThen(retryStep); } - return consumer; } @@ -235,7 +238,7 @@ private Consumer buildConsumer(List> bindings, String consumer = consumer == null ? step : consumer.andThen(step); } } - return consumer; + return consumer == null ? o -> {} : consumer; } /** diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java index c31c27cafd..365543157a 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java @@ -44,9 +44,17 @@ public Optional getValue(String key, Class type) { // -- applyConfigs ----------------------------------------------------------- @Test - void applyConfigsReturnsNullWhenNothingConfigured() { + void applyConfigsReturnsNoOpWhenNothingConfigured() { var loader = new ConfigLoader(mapProvider(Map.of())); - assertThat(loader.applyConfigs()).isNull(); + var base = new BaseConfigurationService(null); + // consumer must be non-null and must leave all defaults unchanged + var consumer = loader.applyConfigs(); + assertThat(consumer).isNotNull(); + var result = ConfigurationService.newOverriddenConfigurationService(base, consumer); + assertThat(result.concurrentReconciliationThreads()) + .isEqualTo(base.concurrentReconciliationThreads()); + assertThat(result.concurrentWorkflowExecutorThreads()) + .isEqualTo(base.concurrentWorkflowExecutorThreads()); } @Test @@ -129,9 +137,9 @@ void applyConfigsOnlyAppliesPresentKeys() { // -- applyControllerConfigs ------------------------------------------------- @Test - void applyControllerConfigsReturnsNullWhenNothingConfigured() { + void applyControllerConfigsReturnsNoOpWhenNothingConfigured() { var loader = new ConfigLoader(mapProvider(Map.of())); - assertThat(loader.applyControllerConfigs("my-controller")).isNull(); + assertThat(loader.applyControllerConfigs("my-controller")).isNotNull(); } @Test @@ -163,8 +171,8 @@ void applyControllerConfigsIsolatesControllersByName() { // alpha gets a consumer (key found), beta gets a consumer (key found) assertThat(loader.applyControllerConfigs("alpha")).isNotNull(); assertThat(loader.applyControllerConfigs("beta")).isNotNull(); - // a controller with no configured keys gets null - assertThat(loader.applyControllerConfigs("gamma")).isNull(); + // a controller with no configured keys still gets a non-null no-op consumer + assertThat(loader.applyControllerConfigs("gamma")).isNotNull(); } @Test diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index db3547cb05..b29be9294a 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -28,14 +28,14 @@ public class TomcatOperator { public static void main(String[] args) throws IOException { - - Operator operator = new Operator(ConfigLoader.DEFAULT.applyConfigs()); + var configLoader = ConfigLoader.getDefault(); + Operator operator = new Operator(configLoader.applyConfigs()); operator.register( new TomcatReconciler(), - ConfigLoader.DEFAULT.applyControllerConfigs(TomcatReconciler.TOMCAT_CONTROLLER_NAME)); + configLoader.applyControllerConfigs(TomcatReconciler.TOMCAT_CONTROLLER_NAME)); operator.register( new WebappReconciler(operator.getKubernetesClient()), - ConfigLoader.DEFAULT.applyControllerConfigs(WebappReconciler.WEBAPP_CONTROLLER_NAME)); + configLoader.applyControllerConfigs(WebappReconciler.WEBAPP_CONTROLLER_NAME)); operator.start(); new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD.")), 8080).start(Exit.NEVER); From cf21fbab6efe09d85cab72ddf76b6801fd828d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 20:41:41 +0100 Subject: [PATCH 40/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../io/javaoperatorsdk/operator/sample/TomcatOperator.java | 5 ++--- .../io/javaoperatorsdk/operator/sample/TomcatReconciler.java | 4 ++-- .../io/javaoperatorsdk/operator/sample/WebappReconciler.java | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index b29be9294a..c597956319 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -31,11 +31,10 @@ public static void main(String[] args) throws IOException { var configLoader = ConfigLoader.getDefault(); Operator operator = new Operator(configLoader.applyConfigs()); operator.register( - new TomcatReconciler(), - configLoader.applyControllerConfigs(TomcatReconciler.TOMCAT_CONTROLLER_NAME)); + new TomcatReconciler(), configLoader.applyControllerConfigs(TomcatReconciler.NAME)); operator.register( new WebappReconciler(operator.getKubernetesClient()), - configLoader.applyControllerConfigs(WebappReconciler.WEBAPP_CONTROLLER_NAME)); + configLoader.applyControllerConfigs(WebappReconciler.NAME)); operator.start(); new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD.")), 8080).start(Exit.NEVER); diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java index 60a0d0bdc2..6bb454eb13 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java @@ -35,10 +35,10 @@ @Dependent(type = DeploymentDependentResource.class), @Dependent(type = ServiceDependentResource.class) }) -@ControllerConfiguration(name = TomcatReconciler.TOMCAT_CONTROLLER_NAME) +@ControllerConfiguration(name = TomcatReconciler.NAME) public class TomcatReconciler implements Reconciler { - public static final String TOMCAT_CONTROLLER_NAME = "tomcat"; + public static final String NAME = "tomcat"; private final Logger log = LoggerFactory.getLogger(getClass()); @Override diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java index e5b1db0505..32d32f0a5b 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java @@ -47,11 +47,11 @@ import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; -@ControllerConfiguration(name = WebappReconciler.WEBAPP_CONTROLLER_NAME) +@ControllerConfiguration(name = WebappReconciler.NAME) public class WebappReconciler implements Reconciler, Cleaner { private static final Logger log = LoggerFactory.getLogger(WebappReconciler.class); - public static final String WEBAPP_CONTROLLER_NAME = "webapp"; + public static final String NAME = "webapp"; private final KubernetesClient kubernetesClient; From e4411df86cbbdaa737c1071e3739e4dfd37d75ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 21:01:43 +0100 Subject: [PATCH 41/58] Unit test to check if we cover all the overrider methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigLoader.java | 55 +++++------ .../api/config/loader/ConfigLoaderTest.java | 94 ++++++++++++++++++- 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index eebb433fb2..438eb30c4f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -50,7 +50,7 @@ public static ConfigLoader getDefault() { // included. Complex objects (KubernetesClient, ExecutorService, …) must be // configured programmatically and are intentionally omitted. // --------------------------------------------------------------------------- - private static final List> OPERATOR_BINDINGS = + static final List> OPERATOR_BINDINGS = List.of( new ConfigBinding<>( "check-crd", @@ -106,35 +106,30 @@ public static ConfigLoader getDefault() { // The key used at runtime is built as: // CONTROLLER_KEY_PREFIX + controllerName + "." + // --------------------------------------------------------------------------- - private static final List, ?>> - CONTROLLER_BINDINGS = - List.of( - new ConfigBinding<>( - "finalizer", String.class, ControllerConfigurationOverrider::withFinalizer), - new ConfigBinding<>( - "generation-aware", - Boolean.class, - ControllerConfigurationOverrider::withGenerationAware), - new ConfigBinding<>( - "label-selector", - String.class, - ControllerConfigurationOverrider::withLabelSelector), - new ConfigBinding<>( - "max-reconciliation-interval", - Duration.class, - ControllerConfigurationOverrider::withReconciliationMaxInterval), - new ConfigBinding<>( - "field-manager", - String.class, - ControllerConfigurationOverrider::withFieldManager), - new ConfigBinding<>( - "trigger-reconciler-on-all-events", - Boolean.class, - ControllerConfigurationOverrider::withTriggerReconcilerOnAllEvents), - new ConfigBinding<>( - "informer-list-limit", - Long.class, - ControllerConfigurationOverrider::withInformerListLimit)); + static final List, ?>> CONTROLLER_BINDINGS = + List.of( + new ConfigBinding<>( + "finalizer", String.class, ControllerConfigurationOverrider::withFinalizer), + new ConfigBinding<>( + "generation-aware", + Boolean.class, + ControllerConfigurationOverrider::withGenerationAware), + new ConfigBinding<>( + "label-selector", String.class, ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "max-reconciliation-interval", + Duration.class, + ControllerConfigurationOverrider::withReconciliationMaxInterval), + new ConfigBinding<>( + "field-manager", String.class, ControllerConfigurationOverrider::withFieldManager), + new ConfigBinding<>( + "trigger-reconciler-on-all-events", + Boolean.class, + ControllerConfigurationOverrider::withTriggerReconcilerOnAllEvents), + new ConfigBinding<>( + "informer-list-limit", + Long.class, + ControllerConfigurationOverrider::withInformerListLimit)); private final ConfigProvider configProvider; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java index 365543157a..765ddf3328 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java @@ -17,14 +17,19 @@ import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; import static org.assertj.core.api.Assertions.assertThat; @@ -200,7 +205,94 @@ public Optional getValue(String key, Class type) { "josdk.controller.ctrl.informer-list-limit"); } - // -- key prefix constants --------------------------------------------------- + // -- binding coverage ------------------------------------------------------- + + /** + * Supported scalar types that DefaultConfigProvider can parse from a string. Every binding's type + * must be one of these. + */ + private static final Set> SUPPORTED_TYPES = + Set.of( + Boolean.class, + boolean.class, + Integer.class, + int.class, + Long.class, + long.class, + Double.class, + double.class, + Duration.class, + String.class); + + @Test + void operatorBindingsCoverAllSingleScalarSettersOnConfigurationServiceOverrider() { + Set expectedSetters = + Arrays.stream(ConfigurationServiceOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> SUPPORTED_TYPES.contains(m.getParameterTypes()[0])) + .filter(m -> m.getReturnType() == ConfigurationServiceOverrider.class) + .map(java.lang.reflect.Method::getName) + .collect(Collectors.toSet()); + + Set boundMethodNames = + ConfigLoader.OPERATOR_BINDINGS.stream() + .flatMap( + b -> + Arrays.stream(ConfigurationServiceOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> isTypeCompatible(m.getParameterTypes()[0], b.type())) + .filter(m -> m.getReturnType() == ConfigurationServiceOverrider.class) + .map(java.lang.reflect.Method::getName)) + .collect(Collectors.toSet()); + + assertThat(boundMethodNames) + .as("Every scalar setter on ConfigurationServiceOverrider must be covered by a binding") + .containsExactlyInAnyOrderElementsOf(expectedSetters); + } + + @Test + void controllerBindingsCoverAllSingleScalarSettersOnControllerConfigurationOverrider() { + Set expectedSetters = + Arrays.stream(ControllerConfigurationOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> SUPPORTED_TYPES.contains(m.getParameterTypes()[0])) + .filter(m -> m.getReturnType() == ControllerConfigurationOverrider.class) + .filter(m -> m.getAnnotation(Deprecated.class) == null) + .map(java.lang.reflect.Method::getName) + .collect(Collectors.toSet()); + + Set boundMethodNames = + ConfigLoader.CONTROLLER_BINDINGS.stream() + .flatMap( + b -> + Arrays.stream(ControllerConfigurationOverrider.class.getMethods()) + .filter(m -> m.getParameterCount() == 1) + .filter(m -> isTypeCompatible(m.getParameterTypes()[0], b.type())) + .filter(m -> m.getReturnType() == ControllerConfigurationOverrider.class) + .filter(m -> m.getAnnotation(Deprecated.class) == null) + .map(java.lang.reflect.Method::getName)) + .collect(Collectors.toSet()); + + assertThat(boundMethodNames) + .as( + "Every scalar setter on ControllerConfigurationOverrider should be covered by a" + + " binding") + .containsExactlyInAnyOrderElementsOf(expectedSetters); + } + + /** Returns true when the two types are the same or one is the boxed/unboxed form of the other. */ + private static boolean isTypeCompatible(Class methodParam, Class bindingType) { + if (methodParam == bindingType) return true; + if (methodParam == boolean.class && bindingType == Boolean.class) return true; + if (methodParam == Boolean.class && bindingType == boolean.class) return true; + if (methodParam == int.class && bindingType == Integer.class) return true; + if (methodParam == Integer.class && bindingType == int.class) return true; + if (methodParam == long.class && bindingType == Long.class) return true; + if (methodParam == Long.class && bindingType == long.class) return true; + if (methodParam == double.class && bindingType == Double.class) return true; + if (methodParam == Double.class && bindingType == double.class) return true; + return false; + } @Test void operatorKeyPrefixIsJosdkDot() { From 823a6b99efbc0413e09aa0ff913b36cc52771815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 21:07:45 +0100 Subject: [PATCH 42/58] small cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigLoaderTest.java | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java index 765ddf3328..7e8d8c1db6 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java @@ -46,8 +46,6 @@ public Optional getValue(String key, Class type) { }; } - // -- applyConfigs ----------------------------------------------------------- - @Test void applyConfigsReturnsNoOpWhenNothingConfigured() { var loader = new ConfigLoader(mapProvider(Map.of())); @@ -205,6 +203,16 @@ public Optional getValue(String key, Class type) { "josdk.controller.ctrl.informer-list-limit"); } + @Test + void operatorKeyPrefixIsJosdkDot() { + assertThat(ConfigLoader.DEFAULT_OPERATOR_KEY_PREFIX).isEqualTo("josdk."); + } + + @Test + void controllerKeyPrefixIsJosdkControllerDot() { + assertThat(ConfigLoader.DEFAULT_CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); + } + // -- binding coverage ------------------------------------------------------- /** @@ -293,14 +301,4 @@ private static boolean isTypeCompatible(Class methodParam, Class bindingTy if (methodParam == Double.class && bindingType == double.class) return true; return false; } - - @Test - void operatorKeyPrefixIsJosdkDot() { - assertThat(ConfigLoader.DEFAULT_OPERATOR_KEY_PREFIX).isEqualTo("josdk."); - } - - @Test - void controllerKeyPrefixIsJosdkControllerDot() { - assertThat(ConfigLoader.DEFAULT_CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); - } } From a6ff350f791eeb146312fc9d8b56e883fb1a8945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 21:15:54 +0100 Subject: [PATCH 43/58] Update operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../operator/api/config/loader/ConfigLoader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index 438eb30c4f..ec372fa187 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -222,7 +222,7 @@ private Consumer> bu * @param bindings the predefined bindings to check * @param keyPrefix when non-null the key stored in the binding is treated as a suffix and this * prefix is prepended before the lookup - * @return a consumer that applies all found values, or {@code null} if none were found + * @return a consumer that applies all found values, or a no-op consumer if none were found */ private Consumer buildConsumer(List> bindings, String keyPrefix) { Consumer consumer = null; From 4cc92862f449419a91dc347cc88d3505a0988c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Feb 2026 21:18:33 +0100 Subject: [PATCH 44/58] javadoc fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/config/loader/ConfigLoader.java | 2 +- .../config/loader/DefaultConfigProvider.java | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java index ec372fa187..96da618907 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java @@ -150,7 +150,7 @@ public ConfigLoader( /** * Returns a {@link Consumer} that applies every operator-level property found in the {@link - * ConfigProvider} to the given {@link ConfigurationServiceOverrider}. Returns {@code null} when + * ConfigProvider} to the given {@link ConfigurationServiceOverrider}. Returns no-op consumer when * no binding has a matching value, preserving the previous behavior. */ public Consumer applyConfigs() { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java index 1c2c7f126f..06d97f709b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java @@ -19,6 +19,25 @@ import java.util.Optional; import java.util.function.Function; +/** + * A {@link ConfigProvider} that resolves configuration values from environment variables and Java + * system properties. + * + *

For a given key, lookup proceeds as follows: + * + *

    + *
  1. The key is converted to an environment variable name by replacing dots and hyphens with + * underscores and converting to upper case (e.g. {@code josdk.cache-sync.timeout} → {@code + * JOSDK_CACHE_SYNC_TIMEOUT}). If an environment variable with that name is set, its value is + * used. + *
  2. If no matching environment variable is found, the key is looked up as a Java system + * property (via {@link System#getProperty(String)}) using the original key name. + *
+ * + *

Environment variables take precedence over system properties when both are set. Supported + * value types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, + * and {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ public class DefaultConfigProvider implements ConfigProvider { private final Function envLookup; From 9e9fd366aa682b9ddab6b839995af5adcda99376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 24 Feb 2026 08:58:17 +0100 Subject: [PATCH 45/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../javaoperatorsdk/operator}/config/loader/ConfigBinding.java | 2 +- .../javaoperatorsdk/operator}/config/loader/ConfigLoader.java | 2 +- .../javaoperatorsdk/operator}/config/loader/ConfigProvider.java | 2 +- .../operator}/config/loader/DefaultConfigProvider.java | 2 +- .../operator}/config/loader/ConfigBindingTest.java | 2 +- .../operator}/config/loader/ConfigLoaderTest.java | 2 +- .../operator}/config/loader/DefaultConfigProviderTest.java | 2 +- .../java/io/javaoperatorsdk/operator/sample/TomcatOperator.java | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename {operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api => operator-framework/src/main/java/io/javaoperatorsdk/operator}/config/loader/ConfigBinding.java (96%) rename {operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api => operator-framework/src/main/java/io/javaoperatorsdk/operator}/config/loader/ConfigLoader.java (99%) rename {operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api => operator-framework/src/main/java/io/javaoperatorsdk/operator}/config/loader/ConfigProvider.java (95%) rename {operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api => operator-framework/src/main/java/io/javaoperatorsdk/operator}/config/loader/DefaultConfigProvider.java (98%) rename {operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api => operator-framework/src/test/java/io/javaoperatorsdk/operator}/config/loader/ConfigBindingTest.java (95%) rename {operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api => operator-framework/src/test/java/io/javaoperatorsdk/operator}/config/loader/ConfigLoaderTest.java (99%) rename {operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api => operator-framework/src/test/java/io/javaoperatorsdk/operator}/config/loader/DefaultConfigProviderTest.java (98%) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBinding.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java similarity index 96% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBinding.java rename to operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java index 069932b189..7cb508b2f1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBinding.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigBinding.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.config.loader; +package io.javaoperatorsdk.operator.config.loader; import java.util.function.BiConsumer; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java similarity index 99% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java rename to operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index 96da618907..22257d5701 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.config.loader; +package io.javaoperatorsdk.operator.config.loader; import java.time.Duration; import java.util.List; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java similarity index 95% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java rename to operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java index 9279439d68..000131ff3b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/ConfigProvider.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.config.loader; +package io.javaoperatorsdk.operator.config.loader; import java.util.Optional; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProvider.java similarity index 98% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java rename to operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProvider.java index 06d97f709b..70628a8b13 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProvider.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProvider.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.config.loader; +package io.javaoperatorsdk.operator.config.loader; import java.time.Duration; import java.util.Optional; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBindingTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java similarity index 95% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBindingTest.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java index 6a1c7aeecd..384ebb600c 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigBindingTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigBindingTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.config.loader; +package io.javaoperatorsdk.operator.config.loader; import java.util.ArrayList; import java.util.List; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java similarity index 99% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index 7e8d8c1db6..88b0d0b7ca 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.config.loader; +package io.javaoperatorsdk.operator.config.loader; import java.time.Duration; import java.util.ArrayList; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProviderTest.java similarity index 98% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProviderTest.java index d8821ffee9..06a3c8489c 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/loader/DefaultConfigProviderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProviderTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.config.loader; +package io.javaoperatorsdk.operator.config.loader; import java.time.Duration; import java.util.concurrent.atomic.AtomicInteger; diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index c597956319..bb37892c11 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -23,7 +23,7 @@ import org.takes.http.FtBasic; import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.api.config.loader.ConfigLoader; +import io.javaoperatorsdk.operator.config.loader.ConfigLoader; public class TomcatOperator { From 05ab4bb36e32882caa0e24a52db58cce06adec02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 24 Feb 2026 17:39:35 +0100 Subject: [PATCH 46/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/config/loader/ConfigLoader.java | 9 +- .../config/loader/DefaultConfigProvider.java | 103 -------------- .../loader/provider/ConfigValueConverter.java | 51 +++++++ .../loader/provider/EnvVarConfigProvider.java | 60 ++++++++ .../provider/PrirityListConfigProvider.java | 45 ++++++ .../provider/PropertiesConfigProvider.java | 74 ++++++++++ .../SystemPropertyConfigProvider.java | 53 +++++++ .../config/loader/ConfigLoaderTest.java | 4 +- .../provider/EnvVarConfigProviderTest.java | 62 +++++++++ .../PriorityListConfigProviderTest.java | 67 +++++++++ .../PropertiesConfigProviderTest.java | 129 ++++++++++++++++++ .../SystemPropertyConfigProviderTest.java} | 57 ++------ 12 files changed, 564 insertions(+), 150 deletions(-) delete mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProvider.java create mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java create mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java create mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PrirityListConfigProvider.java create mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java create mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java rename operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/{DefaultConfigProviderTest.java => provider/SystemPropertyConfigProviderTest.java} (61%) diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index 22257d5701..9d04c3154e 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -23,6 +23,9 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.PrirityListConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.SystemPropertyConfigProvider; import io.javaoperatorsdk.operator.processing.retry.GenericRetry; public class ConfigLoader { @@ -134,7 +137,11 @@ public static ConfigLoader getDefault() { private final ConfigProvider configProvider; public ConfigLoader() { - this(new DefaultConfigProvider(), DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); + this( + new PrirityListConfigProvider( + List.of(new EnvVarConfigProvider(), new SystemPropertyConfigProvider())), + DEFAULT_CONTROLLER_KEY_PREFIX, + DEFAULT_OPERATOR_KEY_PREFIX); } public ConfigLoader(ConfigProvider configProvider) { diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProvider.java deleted file mode 100644 index 70628a8b13..0000000000 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProvider.java +++ /dev/null @@ -1,103 +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.config.loader; - -import java.time.Duration; -import java.util.Optional; -import java.util.function.Function; - -/** - * A {@link ConfigProvider} that resolves configuration values from environment variables and Java - * system properties. - * - *

For a given key, lookup proceeds as follows: - * - *

    - *
  1. The key is converted to an environment variable name by replacing dots and hyphens with - * underscores and converting to upper case (e.g. {@code josdk.cache-sync.timeout} → {@code - * JOSDK_CACHE_SYNC_TIMEOUT}). If an environment variable with that name is set, its value is - * used. - *
  2. If no matching environment variable is found, the key is looked up as a Java system - * property (via {@link System#getProperty(String)}) using the original key name. - *
- * - *

Environment variables take precedence over system properties when both are set. Supported - * value types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, - * and {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). - */ -public class DefaultConfigProvider implements ConfigProvider { - - private final Function envLookup; - - public DefaultConfigProvider() { - this(System::getenv); - } - - DefaultConfigProvider(Function envLookup) { - this.envLookup = envLookup; - } - - /** - * Looks up {@code key} first as an environment variable (dots and hyphens replaced by - * underscores, uppercased, e.g. {@code josdk.cache.sync.timeout} → {@code - * JOSDK_CACHE_SYNC_TIMEOUT}), then as a system property with the key as-is. The environment - * variable takes precedence when both are set. - */ - @Override - public Optional getValue(String key, Class type) { - String raw = resolveRaw(key); - if (raw == null) { - return Optional.empty(); - } - return Optional.of(convert(raw, type)); - } - - private String resolveRaw(String key) { - if (key == null) { - return null; - } - String envKey = toEnvKey(key); - String envValue = envLookup.apply(envKey); - if (envValue != null) { - return envValue; - } - return System.getProperty(key); - } - - private static String toEnvKey(String key) { - return key.trim().replace('.', '_').replace('-', '_').toUpperCase(); - } - - private static T convert(String raw, Class type) { - final Object converted; - if (type == String.class) { - converted = raw; - } else if (type == Boolean.class) { - converted = Boolean.parseBoolean(raw); - } else if (type == Integer.class) { - converted = Integer.parseInt(raw); - } else if (type == Long.class) { - converted = Long.parseLong(raw); - } else if (type == Double.class) { - converted = Double.parseDouble(raw); - } else if (type == Duration.class) { - converted = Duration.parse(raw); - } else { - throw new IllegalArgumentException("Unsupported config type: " + type.getName()); - } - return type.cast(converted); - } -} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java new file mode 100644 index 0000000000..09c5c3fcf2 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/ConfigValueConverter.java @@ -0,0 +1,51 @@ +/* + * 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.config.loader.provider; + +import java.time.Duration; + +/** Utility for converting raw string config values to typed instances. */ +final class ConfigValueConverter { + + private ConfigValueConverter() {} + + /** + * Converts {@code raw} to an instance of {@code type}. Supported types: {@link String}, {@link + * Boolean}, {@link Integer}, {@link Long}, {@link Double}, and {@link Duration} (ISO-8601 format, + * e.g. {@code PT30S}). + * + * @throws IllegalArgumentException if {@code type} is not supported + */ + public static T convert(String raw, Class type) { + final Object converted; + if (type == String.class) { + converted = raw; + } else if (type == Boolean.class) { + converted = Boolean.parseBoolean(raw); + } else if (type == Integer.class) { + converted = Integer.parseInt(raw); + } else if (type == Long.class) { + converted = Long.parseLong(raw); + } else if (type == Double.class) { + converted = Double.parseDouble(raw); + } else if (type == Duration.class) { + converted = Duration.parse(raw); + } else { + throw new IllegalArgumentException("Unsupported config type: " + type.getName()); + } + return type.cast(converted); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java new file mode 100644 index 0000000000..916ee6391d --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProvider.java @@ -0,0 +1,60 @@ +/* + * 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.config.loader.provider; + +import java.util.Optional; +import java.util.function.Function; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that resolves configuration values from environment variables. + * + *

The key is converted to an environment variable name by replacing dots and hyphens with + * underscores and converting to upper case (e.g. {@code josdk.cache-sync.timeout} → {@code + * JOSDK_CACHE_SYNC_TIMEOUT}). + * + *

Supported value types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, + * {@link Double}, and {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class EnvVarConfigProvider implements ConfigProvider { + + private final Function envLookup; + + public EnvVarConfigProvider() { + this(System::getenv); + } + + EnvVarConfigProvider(Function envLookup) { + this.envLookup = envLookup; + } + + @Override + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String raw = envLookup.apply(toEnvKey(key)); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(ConfigValueConverter.convert(raw, type)); + } + + static String toEnvKey(String key) { + return key.trim().replace('.', '_').replace('-', '_').toUpperCase(); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PrirityListConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PrirityListConfigProvider.java new file mode 100644 index 0000000000..8d43f2e0ce --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PrirityListConfigProvider.java @@ -0,0 +1,45 @@ +/* + * 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.config.loader.provider; + +import java.util.List; +import java.util.Optional; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that delegates to an ordered list of providers. Providers are queried in + * list order; the first non-empty result wins. + */ +public class PrirityListConfigProvider implements ConfigProvider { + + private final List providers; + + public PrirityListConfigProvider(List providers) { + this.providers = List.copyOf(providers); + } + + @Override + public Optional getValue(String key, Class type) { + for (ConfigProvider provider : providers) { + Optional value = provider.getValue(key, type); + if (value.isPresent()) { + return value; + } + } + return Optional.empty(); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java new file mode 100644 index 0000000000..01ef5b4b03 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java @@ -0,0 +1,74 @@ +/* + * 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.config.loader.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Properties; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that resolves configuration values from a {@link Properties} file. + * + *

Keys are looked up as-is against the loaded properties. Supported value types are: {@link + * String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, and {@link + * java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class PropertiesConfigProvider implements ConfigProvider { + + private final Properties properties; + + /** + * Loads properties from the given file path. + * + * @throws UncheckedIOException if the file cannot be read + */ + public PropertiesConfigProvider(Path path) { + this.properties = load(path); + } + + /** Uses the supplied {@link Properties} instance directly. */ + public PropertiesConfigProvider(Properties properties) { + this.properties = properties; + } + + @Override + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String raw = properties.getProperty(key); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(ConfigValueConverter.convert(raw, type)); + } + + private static Properties load(Path path) { + try (InputStream in = Files.newInputStream(path)) { + Properties props = new Properties(); + props.load(in); + return props; + } catch (IOException e) { + throw new UncheckedIOException("Failed to load config properties from " + path, e); + } + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java new file mode 100644 index 0000000000..f777eb378f --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java @@ -0,0 +1,53 @@ +/* + * 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.config.loader.provider; + +import java.util.Optional; +import java.util.function.Function; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +/** + * A {@link ConfigProvider} that resolves configuration values from Java system properties via + * {@link System#getProperty(String)}, using the key as-is. + * + *

Supported value types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, + * {@link Double}, and {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class SystemPropertyConfigProvider implements ConfigProvider { + + private final Function propertyLookup; + + public SystemPropertyConfigProvider() { + this(System::getProperty); + } + + SystemPropertyConfigProvider(Function propertyLookup) { + this.propertyLookup = propertyLookup; + } + + @Override + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String raw = propertyLookup.apply(key); + if (raw == null) { + return Optional.empty(); + } + return Optional.of(ConfigValueConverter.convert(raw, type)); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index 88b0d0b7ca..460eebcbc9 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -216,8 +216,8 @@ void controllerKeyPrefixIsJosdkControllerDot() { // -- binding coverage ------------------------------------------------------- /** - * Supported scalar types that DefaultConfigProvider can parse from a string. Every binding's type - * must be one of these. + * Supported scalar types that PrirityListConfigProvider can parse from a string. Every binding's + * type must be one of these. */ private static final Set> SUPPORTED_TYPES = Set.of( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java new file mode 100644 index 0000000000..3a4d07dd60 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/EnvVarConfigProviderTest.java @@ -0,0 +1,62 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class EnvVarConfigProviderTest { + + @Test + void returnsEmptyWhenEnvVariableAbsent() { + var provider = new EnvVarConfigProvider(k -> null); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var provider = new EnvVarConfigProvider(k -> "value"); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsStringFromEnvVariable() { + var provider = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_STRING") ? "from-env" : null); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-env"); + } + + @Test + void convertsDotsAndHyphensToUnderscoresAndUppercases() { + var provider = + new EnvVarConfigProvider(k -> k.equals("JOSDK_CACHE_SYNC_TIMEOUT") ? "PT10S" : null); + assertThat(provider.getValue("josdk.cache-sync.timeout", Duration.class)) + .hasValue(Duration.ofSeconds(10)); + } + + @Test + void throwsForUnsupportedType() { + var provider = + new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_UNSUPPORTED") ? "value" : null); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java new file mode 100644 index 0000000000..b5fa8d3a24 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java @@ -0,0 +1,67 @@ +/* + * 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.config.loader.provider; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PriorityListConfigProviderTest { + + @Test + void returnsEmptyWhenAllProvidersReturnEmpty() { + var provider = + new PrirityListConfigProvider( + List.of( + new EnvVarConfigProvider(k -> null), new SystemPropertyConfigProvider(k -> null))); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void firstProviderWins() { + var provider = + new PrirityListConfigProvider( + List.of( + new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "first" : null), + new SystemPropertyConfigProvider( + k -> k.equals("josdk.test.key") ? "second" : null))); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("first"); + } + + @Test + void fallsBackToLaterProviderWhenEarlierReturnsEmpty() { + var provider = + new PrirityListConfigProvider( + List.of( + new EnvVarConfigProvider(k -> null), + new SystemPropertyConfigProvider( + k -> k.equals("josdk.test.key") ? "from-second" : null))); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); + } + + @Test + void respectsOrderWithThreeProviders() { + var first = new EnvVarConfigProvider(k -> null); + var second = + new SystemPropertyConfigProvider(k -> k.equals("josdk.test.key") ? "from-second" : null); + var third = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "from-third" : null); + + var provider = new PrirityListConfigProvider(List.of(first, second, third)); + assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java new file mode 100644 index 0000000000..c44534eb3a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProviderTest.java @@ -0,0 +1,129 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.config.loader.provider; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class PropertiesConfigProviderTest { + + // -- Properties constructor ------------------------------------------------- + + @Test + void returnsEmptyWhenKeyAbsent() { + var provider = new PropertiesConfigProvider(new Properties()); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var props = new Properties(); + props.setProperty("josdk.test.key", "value"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsString() { + var props = new Properties(); + props.setProperty("josdk.test.string", "hello"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); + } + + @Test + void readsBoolean() { + var props = new Properties(); + props.setProperty("josdk.test.bool", "true"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); + } + + @Test + void readsInteger() { + var props = new Properties(); + props.setProperty("josdk.test.integer", "42"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); + } + + @Test + void readsLong() { + var props = new Properties(); + props.setProperty("josdk.test.long", "123456789"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); + } + + @Test + void readsDouble() { + var props = new Properties(); + props.setProperty("josdk.test.double", "3.14"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.double", Double.class)).hasValue(3.14); + } + + @Test + void readsDuration() { + var props = new Properties(); + props.setProperty("josdk.test.duration", "PT30S"); + var provider = new PropertiesConfigProvider(props); + assertThat(provider.getValue("josdk.test.duration", Duration.class)) + .hasValue(Duration.ofSeconds(30)); + } + + @Test + void throwsForUnsupportedType() { + var props = new Properties(); + props.setProperty("josdk.test.unsupported", "value"); + var provider = new PropertiesConfigProvider(props); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } + + // -- Path constructor ------------------------------------------------------- + + @Test + void loadsFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("test.properties"); + Files.writeString(file, "josdk.test.string=from-file\njosdk.test.integer=7\n"); + + var provider = new PropertiesConfigProvider(file); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-file"); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(7); + } + + @Test + void throwsUncheckedIOExceptionForMissingFile(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.properties"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> new PropertiesConfigProvider(missing)) + .withMessageContaining("does-not-exist.properties"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProviderTest.java similarity index 61% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProviderTest.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProviderTest.java index 06a3c8489c..2399524074 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/DefaultConfigProviderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProviderTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.config.loader; +package io.javaoperatorsdk.operator.config.loader.provider; import java.time.Duration; import java.util.concurrent.atomic.AtomicInteger; @@ -23,61 +23,25 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -class DefaultConfigProviderTest { - - private final DefaultConfigProvider provider = new DefaultConfigProvider(); +class SystemPropertyConfigProviderTest { @Test - void returnsEmptyWhenNeitherEnvNorPropertyIsSet() { + void returnsEmptyWhenPropertyAbsent() { + var provider = new SystemPropertyConfigProvider(k -> null); assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); } - // -- env variable tests ----------------------------------------------------- - - @Test - void readsStringFromEnvVariable() { - var envProvider = - new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_STRING") ? "from-env" : null); - assertThat(envProvider.getValue("josdk.test.string", String.class)).hasValue("from-env"); - } - @Test - void envVariableKeyUsesUppercaseWithUnderscores() { - // dots and hyphens both become underscores, key is uppercased - var envProvider = - new DefaultConfigProvider(k -> k.equals("JOSDK_CACHE_SYNC_TIMEOUT") ? "PT10S" : null); - assertThat(envProvider.getValue("josdk.cache-sync.timeout", Duration.class)) - .hasValue(Duration.ofSeconds(10)); - } - - @Test - void envVariableTakesPrecedenceOverSystemProperty() { - System.setProperty("josdk.test.precedence", "from-sysprop"); - try { - var envProvider = - new DefaultConfigProvider(k -> k.equals("JOSDK_TEST_PRECEDENCE") ? "from-env" : null); - assertThat(envProvider.getValue("josdk.test.precedence", String.class)).hasValue("from-env"); - } finally { - System.clearProperty("josdk.test.precedence"); - } - } - - @Test - void fallsBackToSystemPropertyWhenEnvVariableAbsent() { - System.setProperty("josdk.test.fallback", "from-sysprop"); - try { - var envProvider = new DefaultConfigProvider(k -> null); - assertThat(envProvider.getValue("josdk.test.fallback", String.class)) - .hasValue("from-sysprop"); - } finally { - System.clearProperty("josdk.test.fallback"); - } + void returnsEmptyForNullKey() { + var provider = new SystemPropertyConfigProvider(k -> "value"); + assertThat(provider.getValue(null, String.class)).isEmpty(); } @Test void readsStringFromSystemProperty() { System.setProperty("josdk.test.string", "hello"); try { + var provider = new SystemPropertyConfigProvider(); assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); } finally { System.clearProperty("josdk.test.string"); @@ -88,6 +52,7 @@ void readsStringFromSystemProperty() { void readsBooleanFromSystemProperty() { System.setProperty("josdk.test.bool", "true"); try { + var provider = new SystemPropertyConfigProvider(); assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); } finally { System.clearProperty("josdk.test.bool"); @@ -98,6 +63,7 @@ void readsBooleanFromSystemProperty() { void readsIntegerFromSystemProperty() { System.setProperty("josdk.test.integer", "42"); try { + var provider = new SystemPropertyConfigProvider(); assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); } finally { System.clearProperty("josdk.test.integer"); @@ -108,6 +74,7 @@ void readsIntegerFromSystemProperty() { void readsLongFromSystemProperty() { System.setProperty("josdk.test.long", "123456789"); try { + var provider = new SystemPropertyConfigProvider(); assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); } finally { System.clearProperty("josdk.test.long"); @@ -118,6 +85,7 @@ void readsLongFromSystemProperty() { void readsDurationFromSystemProperty() { System.setProperty("josdk.test.duration", "PT30S"); try { + var provider = new SystemPropertyConfigProvider(); assertThat(provider.getValue("josdk.test.duration", Duration.class)) .hasValue(Duration.ofSeconds(30)); } finally { @@ -129,6 +97,7 @@ void readsDurationFromSystemProperty() { void throwsForUnsupportedType() { System.setProperty("josdk.test.unsupported", "value"); try { + var provider = new SystemPropertyConfigProvider(); assertThatIllegalArgumentException() .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) .withMessageContaining("Unsupported config type"); From 069772fba366a04d8e72b563891392aea46d145f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 24 Feb 2026 18:32:20 +0100 Subject: [PATCH 47/58] sample with smallrye config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/config/loader/ConfigLoader.java | 13 +++- sample-operators/tomcat-operator/pom.xml | 23 +++++++ .../operator/sample/TomcatOperator.java | 15 ++++- .../SmallryeConfigProvider.java | 35 +++++++++++ .../src/main/resources/application.yaml | 62 +++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java create mode 100644 sample-operators/tomcat-operator/src/main/resources/application.yaml diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index 9d04c3154e..9eea2a4721 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -20,6 +20,9 @@ import java.util.Optional; import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; @@ -30,6 +33,8 @@ public class ConfigLoader { + private static final Logger log = LoggerFactory.getLogger(ConfigLoader.class); + private static final ConfigLoader DEFAULT = new ConfigLoader(); public static ConfigLoader getDefault() { @@ -251,7 +256,13 @@ private Consumer buildConsumer(List> bindings, String private Consumer resolveStep(ConfigBinding binding, String key) { return configProvider .getValue(key, binding.type()) - .map(value -> (Consumer) overrider -> binding.setter().accept(overrider, value)) + .map( + value -> + (Consumer) + overrider -> { + log.debug("Found config property: {} = {}", key, value); + binding.setter().accept(overrider, value); + }) .orElse(null); } } diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index b7c3b05c98..c9afd825de 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -39,6 +39,13 @@ pom import + + io.smallrye.config + smallrye-config-bom + 3.16.0 + pom + import + @@ -92,6 +99,22 @@ operator-framework-junit test + + + io.smallrye.config + smallrye-config + 3.11.4 + + + io.smallrye.config + smallrye-config-source-yaml + 3.11.4 + + + org.eclipse.microprofile.config + microprofile-config-api + 3.1 + diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index bb37892c11..7cc5d9027e 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.sample; import java.io.IOException; +import java.net.URL; import org.takes.facets.fork.FkRegex; import org.takes.facets.fork.TkFork; @@ -24,11 +25,23 @@ import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.config.loader.ConfigLoader; +import io.javaoperatorsdk.operator.sample.smallryeconfig.SmallryeConfigProvider; +import io.smallrye.config.SmallRyeConfigBuilder; +import io.smallrye.config.source.yaml.YamlConfigSource; public class TomcatOperator { public static void main(String[] args) throws IOException { - var configLoader = ConfigLoader.getDefault(); + + URL configUrl = TomcatOperator.class.getResource("/application.yaml"); + if (configUrl == null) { + throw new IllegalStateException("application.yaml not found on classpath"); + } + var configLoader = + new ConfigLoader( + new SmallryeConfigProvider( + new SmallRyeConfigBuilder().withSources(new YamlConfigSource(configUrl)).build())); + Operator operator = new Operator(configLoader.applyConfigs()); operator.register( new TomcatReconciler(), configLoader.applyControllerConfigs(TomcatReconciler.NAME)); diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java new file mode 100644 index 0000000000..b415566af2 --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java @@ -0,0 +1,35 @@ +/* + * 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.sample.smallryeconfig; + +import java.util.Optional; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; +import io.smallrye.config.SmallRyeConfig; + +public class SmallryeConfigProvider implements ConfigProvider { + + private final SmallRyeConfig smallRyeConfig; + + public SmallryeConfigProvider(SmallRyeConfig smallRyeConfig) { + this.smallRyeConfig = smallRyeConfig; + } + + @Override + public Optional getValue(String key, Class type) { + return smallRyeConfig.getOptionalValue(key, type); + } +} diff --git a/sample-operators/tomcat-operator/src/main/resources/application.yaml b/sample-operators/tomcat-operator/src/main/resources/application.yaml new file mode 100644 index 0000000000..128ee5eabc --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/resources/application.yaml @@ -0,0 +1,62 @@ +# +# Copyright Java Operator SDK Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# JOSDK operator-level configuration (josdk.) +josdk: + check-crd: true + close-client-on-stop: true + use-ssa-to-patch-primary-resource: false + clone-secondary-resources-when-getting-from-cache: false + reconciliation: + termination-timeout: PT30S + concurrent-threads: 10 + workflow: + executor-threads: 10 + informer: + stop-on-error-during-startup: true + cache-sync-timeout: PT2M + dependent-resources: + ssa-based-create-update-match: true + + # Controller-level configuration (josdk.controller..) + controller: + tomcat: + finalizer: tomcat.sample.javaoperatorsdk.io/finalizer + generation-aware: true + label-selector: "" + max-reconciliation-interval: PT10M + field-manager: tomcat-controller + trigger-reconciler-on-all-events: false + informer-list-limit: 500 + retry: + max-attempts: 10 + initial-interval: 2000 + interval-multiplier: 1.5 + max-interval: 60000 + + webapp: + finalizer: webapp.sample.javaoperatorsdk.io/finalizer + generation-aware: true + label-selector: "" + max-reconciliation-interval: PT10M + field-manager: webapp-controller + trigger-reconciler-on-all-events: false + informer-list-limit: 500 + retry: + max-attempts: 10 + initial-interval: 2000 + interval-multiplier: 1.5 + max-interval: 60000 From 81d3e49fac8d3c64b7613b1f57e06353e6141696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 25 Feb 2026 12:32:44 +0100 Subject: [PATCH 48/58] yaml config provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/config/loader/ConfigLoader.java | 4 +- ...=> AgregatePrirityListConfigProvider.java} | 4 +- .../loader/provider/YamlConfigProvider.java | 89 +++++++++++++++++++ .../config/loader/ConfigLoaderTest.java | 4 +- .../PriorityListConfigProviderTest.java | 8 +- 5 files changed, 99 insertions(+), 10 deletions(-) rename operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/{PrirityListConfigProvider.java => AgregatePrirityListConfigProvider.java} (89%) create mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index 9eea2a4721..a15b4509d4 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -26,8 +26,8 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.config.loader.provider.AgregatePrirityListConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; -import io.javaoperatorsdk.operator.config.loader.provider.PrirityListConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.SystemPropertyConfigProvider; import io.javaoperatorsdk.operator.processing.retry.GenericRetry; @@ -143,7 +143,7 @@ public static ConfigLoader getDefault() { public ConfigLoader() { this( - new PrirityListConfigProvider( + new AgregatePrirityListConfigProvider( List.of(new EnvVarConfigProvider(), new SystemPropertyConfigProvider())), DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PrirityListConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePrirityListConfigProvider.java similarity index 89% rename from operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PrirityListConfigProvider.java rename to operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePrirityListConfigProvider.java index 8d43f2e0ce..8da7d28f2f 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PrirityListConfigProvider.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePrirityListConfigProvider.java @@ -24,11 +24,11 @@ * A {@link ConfigProvider} that delegates to an ordered list of providers. Providers are queried in * list order; the first non-empty result wins. */ -public class PrirityListConfigProvider implements ConfigProvider { +public class AgregatePrirityListConfigProvider implements ConfigProvider { private final List providers; - public PrirityListConfigProvider(List providers) { + public AgregatePrirityListConfigProvider(List providers) { this.providers = List.copyOf(providers); } diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java new file mode 100644 index 0000000000..52b07b011d --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProvider.java @@ -0,0 +1,89 @@ +/* + * 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.config.loader.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; + +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +/** + * A {@link ConfigProvider} that resolves configuration values from a YAML file. + * + *

Keys use dot-separated notation to address nested YAML mappings (e.g. {@code + * josdk.cache-sync.timeout} maps to {@code josdk → cache-sync → timeout} in the YAML document). + * Leaf values are converted to the requested type via {@link ConfigValueConverter}. Supported value + * types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, {@link Double}, and + * {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). + */ +public class YamlConfigProvider implements ConfigProvider { + + private static final ObjectMapper MAPPER = new ObjectMapper(new YAMLFactory()); + + private final Map data; + + /** + * Loads YAML from the given file path. + * + * @throws UncheckedIOException if the file cannot be read + */ + public YamlConfigProvider(Path path) { + this.data = load(path); + } + + /** Uses the supplied map directly (useful for testing). */ + public YamlConfigProvider(Map data) { + this.data = data; + } + + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + if (key == null) { + return Optional.empty(); + } + String[] parts = key.split("\\.", -1); + Object current = data; + for (String part : parts) { + if (!(current instanceof Map)) { + return Optional.empty(); + } + current = ((Map) current).get(part); + if (current == null) { + return Optional.empty(); + } + } + return Optional.of(ConfigValueConverter.convert(current.toString(), type)); + } + + @SuppressWarnings("unchecked") + private static Map load(Path path) { + try (InputStream in = Files.newInputStream(path)) { + Map result = MAPPER.readValue(in, Map.class); + return result != null ? result : Map.of(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load config YAML from " + path, e); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index 460eebcbc9..e59d943f17 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -216,8 +216,8 @@ void controllerKeyPrefixIsJosdkControllerDot() { // -- binding coverage ------------------------------------------------------- /** - * Supported scalar types that PrirityListConfigProvider can parse from a string. Every binding's - * type must be one of these. + * Supported scalar types that AgregatePrirityListConfigProvider can parse from a string. Every + * binding's type must be one of these. */ private static final Set> SUPPORTED_TYPES = Set.of( diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java index b5fa8d3a24..85ec2f2d28 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java @@ -26,7 +26,7 @@ class PriorityListConfigProviderTest { @Test void returnsEmptyWhenAllProvidersReturnEmpty() { var provider = - new PrirityListConfigProvider( + new AgregatePrirityListConfigProvider( List.of( new EnvVarConfigProvider(k -> null), new SystemPropertyConfigProvider(k -> null))); assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); @@ -35,7 +35,7 @@ void returnsEmptyWhenAllProvidersReturnEmpty() { @Test void firstProviderWins() { var provider = - new PrirityListConfigProvider( + new AgregatePrirityListConfigProvider( List.of( new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "first" : null), new SystemPropertyConfigProvider( @@ -46,7 +46,7 @@ void firstProviderWins() { @Test void fallsBackToLaterProviderWhenEarlierReturnsEmpty() { var provider = - new PrirityListConfigProvider( + new AgregatePrirityListConfigProvider( List.of( new EnvVarConfigProvider(k -> null), new SystemPropertyConfigProvider( @@ -61,7 +61,7 @@ void respectsOrderWithThreeProviders() { new SystemPropertyConfigProvider(k -> k.equals("josdk.test.key") ? "from-second" : null); var third = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "from-third" : null); - var provider = new PrirityListConfigProvider(List.of(first, second, third)); + var provider = new AgregatePrirityListConfigProvider(List.of(first, second, third)); assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); } } From 527013ba70d3f87e542228aa69eb5c60fda76872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 25 Feb 2026 14:51:26 +0100 Subject: [PATCH 49/58] add leader election configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/config/loader/ConfigLoader.java | 76 +++++++++++++++++- .../config/loader/ConfigLoaderTest.java | 79 +++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index a15b4509d4..79e0303066 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -26,6 +26,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfigurationBuilder; import io.javaoperatorsdk.operator.config.loader.provider.AgregatePrirityListConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.SystemPropertyConfigProvider; @@ -101,6 +102,17 @@ public static ConfigLoader getDefault() { Boolean.class, ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache)); + // --------------------------------------------------------------------------- + // Operator-level leader-election property keys + // --------------------------------------------------------------------------- + static final String LEADER_ELECTION_ENABLED_KEY = "leader-election.enabled"; + static final String LEADER_ELECTION_LEASE_NAME_KEY = "leader-election.lease-name"; + static final String LEADER_ELECTION_LEASE_NAMESPACE_KEY = "leader-election.lease-namespace"; + static final String LEADER_ELECTION_IDENTITY_KEY = "leader-election.identity"; + static final String LEADER_ELECTION_LEASE_DURATION_KEY = "leader-election.lease-duration"; + static final String LEADER_ELECTION_RENEW_DEADLINE_KEY = "leader-election.renew-deadline"; + static final String LEADER_ELECTION_RETRY_PERIOD_KEY = "leader-election.retry-period"; + // --------------------------------------------------------------------------- // Controller-level retry property suffixes // --------------------------------------------------------------------------- @@ -166,7 +178,15 @@ public ConfigLoader( * no binding has a matching value, preserving the previous behavior. */ public Consumer applyConfigs() { - return buildConsumer(OPERATOR_BINDINGS, operatorKeyPrefix); + Consumer consumer = + buildConsumer(OPERATOR_BINDINGS, operatorKeyPrefix); + + Consumer leaderElectionStep = + buildLeaderElectionConsumer(operatorKeyPrefix); + if (leaderElectionStep != null) { + consumer = consumer.andThen(leaderElectionStep); + } + return consumer; } /** @@ -226,6 +246,60 @@ private Consumer> bu }; } + /** + * If leader election is explicitly disabled via {@code leader-election.enabled=false}, returns + * {@code null}. Otherwise, if at least one leader-election property is present (with {@code + * leader-election.lease-name} being required), returns a {@link Consumer} that builds a {@link + * io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration} via {@link + * LeaderElectionConfigurationBuilder} and applies it to the overrider. Returns {@code null} when + * no leader-election properties are present at all. + */ + private Consumer buildLeaderElectionConsumer(String prefix) { + Optional enabled = + configProvider.getValue(prefix + LEADER_ELECTION_ENABLED_KEY, Boolean.class); + if (enabled.isPresent() && !enabled.get()) { + return null; + } + + Optional leaseName = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_NAME_KEY, String.class); + Optional leaseNamespace = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_NAMESPACE_KEY, String.class); + Optional identity = + configProvider.getValue(prefix + LEADER_ELECTION_IDENTITY_KEY, String.class); + Optional leaseDuration = + configProvider.getValue(prefix + LEADER_ELECTION_LEASE_DURATION_KEY, Duration.class); + Optional renewDeadline = + configProvider.getValue(prefix + LEADER_ELECTION_RENEW_DEADLINE_KEY, Duration.class); + Optional retryPeriod = + configProvider.getValue(prefix + LEADER_ELECTION_RETRY_PERIOD_KEY, Duration.class); + + if (leaseName.isEmpty() + && leaseNamespace.isEmpty() + && identity.isEmpty() + && leaseDuration.isEmpty() + && renewDeadline.isEmpty() + && retryPeriod.isEmpty()) { + return null; + } + + return overrider -> { + var builder = + LeaderElectionConfigurationBuilder.aLeaderElectionConfiguration( + leaseName.orElseThrow( + () -> + new IllegalStateException( + "leader-election.lease-name must be set when configuring leader" + + " election"))); + leaseNamespace.ifPresent(builder::withLeaseNamespace); + identity.ifPresent(builder::withIdentity); + leaseDuration.ifPresent(builder::withLeaseDuration); + renewDeadline.ifPresent(builder::withRenewDeadline); + retryPeriod.ifPresent(builder::withRetryPeriod); + overrider.withLeaderElectionConfiguration(builder.build()); + }; + } + /** * Iterates {@code bindings} and, for each one whose key (optionally prefixed by {@code * keyPrefix}) is present in the {@link ConfigProvider}, accumulates a call to the binding's diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index e59d943f17..70e18ddb57 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -32,6 +32,7 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; class ConfigLoaderTest { @@ -288,6 +289,84 @@ void controllerBindingsCoverAllSingleScalarSettersOnControllerConfigurationOverr .containsExactlyInAnyOrderElementsOf(expectedSetters); } + // -- leader election -------------------------------------------------------- + + @Test + void leaderElectionIsNotConfiguredWhenNoPropertiesPresent() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()).isEmpty(); + } + + @Test + void leaderElectionIsNotConfiguredWhenExplicitlyDisabled() { + var values = new HashMap(); + values.put("josdk.leader-election.enabled", false); + values.put("josdk.leader-election.lease-name", "my-lease"); + var loader = new ConfigLoader(mapProvider(values)); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()).isEmpty(); + } + + @Test + void leaderElectionConfiguredWithLeaseNameOnly() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.leader-election.lease-name", "my-lease"))); + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + assertThat(result.getLeaderElectionConfiguration()) + .hasValueSatisfying( + le -> { + assertThat(le.getLeaseName()).isEqualTo("my-lease"); + assertThat(le.getLeaseNamespace()).isEmpty(); + assertThat(le.getIdentity()).isEmpty(); + }); + } + + @Test + void leaderElectionConfiguredWithAllProperties() { + var values = new HashMap(); + values.put("josdk.leader-election.enabled", true); + values.put("josdk.leader-election.lease-name", "my-lease"); + values.put("josdk.leader-election.lease-namespace", "my-ns"); + values.put("josdk.leader-election.identity", "pod-1"); + values.put("josdk.leader-election.lease-duration", Duration.ofSeconds(20)); + values.put("josdk.leader-election.renew-deadline", Duration.ofSeconds(15)); + values.put("josdk.leader-election.retry-period", Duration.ofSeconds(3)); + var loader = new ConfigLoader(mapProvider(values)); + + var base = new BaseConfigurationService(null); + var result = + ConfigurationService.newOverriddenConfigurationService(base, loader.applyConfigs()); + + assertThat(result.getLeaderElectionConfiguration()) + .hasValueSatisfying( + le -> { + assertThat(le.getLeaseName()).isEqualTo("my-lease"); + assertThat(le.getLeaseNamespace()).hasValue("my-ns"); + assertThat(le.getIdentity()).hasValue("pod-1"); + assertThat(le.getLeaseDuration()).isEqualTo(Duration.ofSeconds(20)); + assertThat(le.getRenewDeadline()).isEqualTo(Duration.ofSeconds(15)); + assertThat(le.getRetryPeriod()).isEqualTo(Duration.ofSeconds(3)); + }); + } + + @Test + void leaderElectionMissingLeaseNameThrowsWhenOtherPropertiesPresent() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.leader-election.lease-namespace", "my-ns"))); + var base = new BaseConfigurationService(null); + var consumer = loader.applyConfigs(); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> ConfigurationService.newOverriddenConfigurationService(base, consumer)) + .withMessageContaining("lease-name"); + } + /** Returns true when the two types are the same or one is the boxed/unboxed form of the other. */ private static boolean isTypeCompatible(Class methodParam, Class bindingType) { if (methodParam == bindingType) return true; From 35f22ca98e0868b11e5188e9c49fd00e1eb77fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 25 Feb 2026 17:11:47 +0100 Subject: [PATCH 50/58] rate limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/config/loader/ConfigLoader.java | 44 ++++++++++++++++++- .../config/loader/ConfigLoaderTest.java | 25 ++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index 79e0303066..d93449a32f 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -30,6 +30,7 @@ import io.javaoperatorsdk.operator.config.loader.provider.AgregatePrirityListConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.SystemPropertyConfigProvider; +import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; import io.javaoperatorsdk.operator.processing.retry.GenericRetry; public class ConfigLoader { @@ -121,6 +122,12 @@ public static ConfigLoader getDefault() { static final String RETRY_INTERVAL_MULTIPLIER_SUFFIX = "retry.interval-multiplier"; static final String RETRY_MAX_INTERVAL_SUFFIX = "retry.max-interval"; + // --------------------------------------------------------------------------- + // Controller-level rate-limiter property suffixes + // --------------------------------------------------------------------------- + static final String RATE_LIMITER_REFRESH_PERIOD_SUFFIX = "rate-limiter.refresh-period"; + static final String RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX = "rate-limiter.limit-for-period"; + // --------------------------------------------------------------------------- // Controller-level (ControllerConfigurationOverrider) bindings // The key used at runtime is built as: @@ -147,7 +154,11 @@ public static ConfigLoader getDefault() { Boolean.class, ControllerConfigurationOverrider::withTriggerReconcilerOnAllEvents), new ConfigBinding<>( - "informer-list-limit", + "informer.label-selector", + String.class, + ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "informer.list-limit", Long.class, ControllerConfigurationOverrider::withInformerListLimit)); @@ -210,6 +221,11 @@ Consumer> applyControllerConfigs(String cont if (retryStep != null) { consumer = consumer == null ? retryStep : consumer.andThen(retryStep); } + Consumer> rateLimiterStep = + buildRateLimiterConsumer(prefix); + if (rateLimiterStep != null) { + consumer = consumer.andThen(rateLimiterStep); + } return consumer; } @@ -246,6 +262,32 @@ private Consumer> bu }; } + /** + * Returns a {@link Consumer} that builds a {@link LinearRateLimiter} only if {@code + * rate-limiter.limit-for-period} is present and positive (a non-positive value would deactivate + * the limiter and is therefore treated as absent). {@code rate-limiter.refresh-period} is applied + * when also present; otherwise the default refresh period is used. Returns {@code null} when no + * effective rate-limiter configuration is found. + */ + private + Consumer> buildRateLimiterConsumer(String prefix) { + Optional refreshPeriod = + configProvider.getValue(prefix + RATE_LIMITER_REFRESH_PERIOD_SUFFIX, Duration.class); + Optional limitForPeriod = + configProvider.getValue(prefix + RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX, Integer.class); + + if (limitForPeriod.isEmpty() || limitForPeriod.get() <= 0) { + return null; + } + + return overrider -> { + var rateLimiter = + new LinearRateLimiter( + refreshPeriod.orElse(LinearRateLimiter.DEFAULT_REFRESH_PERIOD), limitForPeriod.get()); + overrider.withRateLimiter(rateLimiter); + }; + } + /** * If leader election is explicitly disabled via {@code leader-election.enabled=false}, returns * {@code null}. Otherwise, if at least one leader-election property is present (with {@code diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index 70e18ddb57..4fe9d50c2a 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -201,7 +201,10 @@ public Optional getValue(String key, Class type) { "josdk.controller.ctrl.max-reconciliation-interval", "josdk.controller.ctrl.field-manager", "josdk.controller.ctrl.trigger-reconciler-on-all-events", - "josdk.controller.ctrl.informer-list-limit"); + "josdk.controller.ctrl.informer.label-selector", + "josdk.controller.ctrl.informer.list-limit", + "josdk.controller.ctrl.rate-limiter.refresh-period", + "josdk.controller.ctrl.rate-limiter.limit-for-period"); } @Test @@ -214,6 +217,26 @@ void controllerKeyPrefixIsJosdkControllerDot() { assertThat(ConfigLoader.DEFAULT_CONTROLLER_KEY_PREFIX).isEqualTo("josdk.controller."); } + // -- rate limiter ----------------------------------------------------------- + + @Test + void rateLimiterQueriesExpectedKeys() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.rate-limiter.refresh-period", + "josdk.controller.ctrl.rate-limiter.limit-for-period"); + } + // -- binding coverage ------------------------------------------------------- /** From a6d4e4eae159ca8bffb7286f3ec4c020805a7812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 26 Feb 2026 13:04:49 +0100 Subject: [PATCH 51/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../provider/YamlConfigProviderTest.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java new file mode 100644 index 0000000000..4f8c53ac38 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/YamlConfigProviderTest.java @@ -0,0 +1,144 @@ +/* + * 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.config.loader.provider; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class YamlConfigProviderTest { + + // -- Map constructor -------------------------------------------------------- + + @Test + void returnsEmptyWhenKeyAbsent() { + var provider = new YamlConfigProvider(Map.of()); + assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyForNullKey() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", "value"))); + assertThat(provider.getValue(null, String.class)).isEmpty(); + } + + @Test + void readsTopLevelString() { + var provider = new YamlConfigProvider(Map.of("key", "hello")); + assertThat(provider.getValue("key", String.class)).hasValue("hello"); + } + + @Test + void readsNestedString() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("string", "hello")))); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); + } + + @Test + void readsBoolean() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("bool", "true")))); + assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); + } + + @Test + void readsInteger() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("integer", 42)))); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); + } + + @Test + void readsLong() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("long", 123456789L)))); + assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); + } + + @Test + void readsDouble() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("double", "3.14")))); + assertThat(provider.getValue("josdk.test.double", Double.class)).hasValue(3.14); + } + + @Test + void readsDuration() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("duration", "PT30S")))); + assertThat(provider.getValue("josdk.test.duration", Duration.class)) + .hasValue(Duration.ofSeconds(30)); + } + + @Test + void returnsEmptyWhenIntermediateSegmentMissing() { + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("other", "value"))); + assertThat(provider.getValue("josdk.test.key", String.class)).isEmpty(); + } + + @Test + void returnsEmptyWhenIntermediateSegmentIsLeaf() { + // "josdk.test" is a leaf – trying to drill further should return empty + var provider = new YamlConfigProvider(Map.of("josdk", Map.of("test", "leaf"))); + assertThat(provider.getValue("josdk.test.key", String.class)).isEmpty(); + } + + @Test + void throwsForUnsupportedType() { + var provider = + new YamlConfigProvider(Map.of("josdk", Map.of("test", Map.of("unsupported", "value")))); + assertThatIllegalArgumentException() + .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) + .withMessageContaining("Unsupported config type"); + } + + // -- Path constructor ------------------------------------------------------- + + @Test + void loadsFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("test.yaml"); + Files.writeString( + file, + """ + josdk: + test: + string: from-file + integer: 7 + """); + + var provider = new YamlConfigProvider(file); + assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("from-file"); + assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(7); + } + + @Test + void throwsUncheckedIOExceptionForMissingFile(@TempDir Path dir) { + Path missing = dir.resolve("does-not-exist.yaml"); + assertThatExceptionOfType(UncheckedIOException.class) + .isThrownBy(() -> new YamlConfigProvider(missing)) + .withMessageContaining("does-not-exist.yaml"); + } +} From dc6a277b55871ab37d5c916dfa01f6c2e1ff85b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 26 Feb 2026 17:40:53 +0100 Subject: [PATCH 52/58] Cleanup and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../en/docs/documentation/configuration.md | 210 +++++++++++++++++- .../operator/config/loader/ConfigLoader.java | 4 +- .../provider/PropertiesConfigProvider.java | 5 + .../SystemPropertyConfigProvider.java | 53 ----- .../PriorityListConfigProviderTest.java | 21 +- .../SystemPropertyConfigProviderTest.java | 108 --------- 6 files changed, 228 insertions(+), 173 deletions(-) delete mode 100644 operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java delete mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProviderTest.java diff --git a/docs/content/en/docs/documentation/configuration.md b/docs/content/en/docs/documentation/configuration.md index 888804628f..e761f7e1f5 100644 --- a/docs/content/en/docs/documentation/configuration.md +++ b/docs/content/en/docs/documentation/configuration.md @@ -149,6 +149,212 @@ For more information on how to use this feature, we recommend looking at how thi `KubernetesDependentResource` in the core framework, `SchemaDependentResource` in the samples or `CustomAnnotationDep` in the `BaseConfigurationServiceTest` test class. -## EventSource-level configuration +## Loading Configuration from External Sources + +JOSDK ships a `ConfigLoader` that bridges any key-value configuration source to the operator and +controller configuration APIs. This lets you drive operator behaviour from environment variables, +system properties, YAML files, or any config library (MicroProfile Config, SmallRye Config, +Spring Environment, etc.) without writing glue code by hand. + +### Architecture + +The system is built around two thin abstractions: + +- **[`ConfigProvider`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigProvider.java)** + — a single-method interface that resolves a typed value for a dot-separated key: + + ```java + public interface ConfigProvider { + Optional getValue(String key, Class type); + } + ``` + +- **[`ConfigLoader`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java)** + — reads all known JOSDK keys from a `ConfigProvider` and returns + `Consumer` / `Consumer>` + values that you pass directly to the `Operator` constructor or `operator.register()`. + +The default `ConfigLoader` (no-arg constructor) stacks environment variables over system +properties: environment variables win, system properties are the fallback. + +```java +// uses env vars + system properties out of the box +Operator operator = new Operator(ConfigLoader.getDefault().applyConfigs()); +``` + +### Built-in Providers + +| Provider | Source | Key mapping | +|---|---|---| +| `EnvVarConfigProvider` | `System.getenv()` | dots and hyphens → underscores, upper-cased (`josdk.check-crd` → `JOSDK_CHECK_CRD`) | +| `PropertiesConfigProvider` | `java.util.Properties` or `.properties` file | key used as-is; use `PropertiesConfigProvider.systemProperties()` to read Java system properties | +| `YamlConfigProvider` | YAML file | dot-separated key traverses nested mappings | +| `AgregatePrirityListConfigProvider` | ordered list of providers | first non-empty result wins | + +All string-based providers convert values to the target type automatically. +Supported types: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Duration` (ISO-8601, e.g. `PT30S`). + +### Plugging in Any Config Library + +`ConfigProvider` is a single-method interface, so adapting any config library takes only a few +lines. As an example, here is an adapter for +[SmallRye Config](https://smallrye.io/smallrye-config/): + +```java +public class SmallRyeConfigProvider implements ConfigProvider { + + private final SmallRyeConfig config; + + public SmallRyeConfigProvider(SmallRyeConfig config) { + this.config = config; + } + + @Override + public Optional getValue(String key, Class type) { + return config.getOptionalValue(key, type); + } +} +``` + +The same pattern applies to MicroProfile Config, Spring `Environment`, Apache Commons +Configuration, or any other library that can look up typed values by string key. + +### Wiring Everything Together + +Pass the `ConfigLoader` results when constructing the operator and registering reconcilers: + +```java +// Load operator-wide config from a YAML file via SmallRye Config +URL configUrl = MyOperator.class.getResource("/application.yaml"); +var configLoader = new ConfigLoader( + new SmallRyeConfigProvider( + new SmallRyeConfigBuilder() + .withSources(new YamlConfigSource(configUrl)) + .build())); + +// applyConfigs() → Consumer +Operator operator = new Operator(configLoader.applyConfigs()); + +// applyControllerConfigs(name) → Consumer> +operator.register(new MyReconciler(), + configLoader.applyControllerConfigs(MyReconciler.NAME)); +``` + +Only keys that are actually present in the source are applied; everything else retains its +programmatic or annotation-based default. + +You can also compose multiple sources with explicit priority using +`AgregatePrirityListConfigProvider`: + +```java +var configLoader = new ConfigLoader( + new AgregatePrirityListConfigProvider(List.of( + new EnvVarConfigProvider(), // highest priority + PropertiesConfigProvider.systemProperties(), + new YamlConfigProvider(Path.of("config/operator.yaml")) // lowest priority + ))); +``` + +### Operator-Level Configuration Keys + +All operator-level keys are prefixed with `josdk.`. + +#### General + +| Key | Type | Description | +|---|---|---| +| `josdk.check-crd` | `Boolean` | Validate CRDs against local model on startup | +| `josdk.close-client-on-stop` | `Boolean` | Close the Kubernetes client when the operator stops | +| `josdk.use-ssa-to-patch-primary-resource` | `Boolean` | Use Server-Side Apply to patch the primary resource | +| `josdk.clone-secondary-resources-when-getting-from-cache` | `Boolean` | Clone secondary resources on cache reads | + +#### Reconciliation + +| Key | Type | Description | +|---|---|---| +| `josdk.reconciliation.concurrent-threads` | `Integer` | Thread pool size for reconciliation | +| `josdk.reconciliation.termination-timeout` | `Duration` | How long to wait for in-flight reconciliations to finish on shutdown | + +#### Workflow + +| Key | Type | Description | +|---|---|---| +| `josdk.workflow.executor-threads` | `Integer` | Thread pool size for workflow execution | + +#### Informer + +| Key | Type | Description | +|---|---|---| +| `josdk.informer.cache-sync-timeout` | `Duration` | Timeout for the initial informer cache sync | +| `josdk.informer.stop-on-error-during-startup` | `Boolean` | Stop the operator if an informer fails to start | + +#### Dependent Resources + +| Key | Type | Description | +|---|---|---| +| `josdk.dependent-resources.ssa-based-create-update-match` | `Boolean` | Use SSA-based matching for dependent resource create/update | + +#### Leader Election + +Leader election is activated when at least one `josdk.leader-election.*` key is present. +`josdk.leader-election.lease-name` is required when any other leader-election key is set. +Setting `josdk.leader-election.enabled=false` suppresses leader election even if other keys are +present. + +| Key | Type | Description | +|---|---|---| +| `josdk.leader-election.enabled` | `Boolean` | Explicitly enable (`true`) or disable (`false`) leader election | +| `josdk.leader-election.lease-name` | `String` | **Required.** Name of the Kubernetes Lease object used for leader election | +| `josdk.leader-election.lease-namespace` | `String` | Namespace for the Lease object (defaults to the operator's namespace) | +| `josdk.leader-election.identity` | `String` | Unique identity for this instance; defaults to the pod name | +| `josdk.leader-election.lease-duration` | `Duration` | How long a lease is valid (default `PT15S`) | +| `josdk.leader-election.renew-deadline` | `Duration` | How long the leader tries to renew before giving up (default `PT10S`) | +| `josdk.leader-election.retry-period` | `Duration` | How often a candidate polls while waiting to become leader (default `PT2S`) | + +### Controller-Level Configuration Keys + +All controller-level keys are prefixed with `josdk.controller..`, where +`` is the value returned by the reconciler's name (typically set via +`@ControllerConfiguration(name = "...")`). + +#### General + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..finalizer` | `String` | Finalizer string added to managed resources | +| `josdk.controller..generation-aware` | `Boolean` | Skip reconciliation when the resource generation has not changed | +| `josdk.controller..label-selector` | `String` | Label selector to filter watched resources | +| `josdk.controller..max-reconciliation-interval` | `Duration` | Maximum interval between reconciliations even without events | +| `josdk.controller..field-manager` | `String` | Field manager name used for SSA operations | +| `josdk.controller..trigger-reconciler-on-all-events` | `Boolean` | Trigger reconciliation on every event, not only meaningful changes | + +#### Informer + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..informer.label-selector` | `String` | Label selector for the primary resource informer (alias for `label-selector`) | +| `josdk.controller..informer.list-limit` | `Long` | Page size for paginated informer list requests; omit for no pagination | + +#### Retry + +If any `retry.*` key is present, a `GenericRetry` is configured starting from the +[default limited exponential retry](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java). +Only explicitly set keys override the defaults. + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..retry.max-attempts` | `Integer` | Maximum number of retry attempts | +| `josdk.controller..retry.initial-interval` | `Long` (ms) | Initial backoff interval in milliseconds | +| `josdk.controller..retry.interval-multiplier` | `Double` | Exponential backoff multiplier | +| `josdk.controller..retry.max-interval` | `Long` (ms) | Maximum backoff interval in milliseconds | + +#### Rate Limiter + +The rate limiter is only activated when `rate-limiter.limit-for-period` is present and has a +positive value. `rate-limiter.refresh-period` is optional and falls back to the default of 10 s. + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..rate-limiter.limit-for-period` | `Integer` | Maximum number of reconciliations allowed per refresh period. Must be positive to activate the limiter | +| `josdk.controller..rate-limiter.refresh-period` | `Duration` | Window over which the limit is counted (default `PT10S`) | -TODO diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index d93449a32f..cc3a044112 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -29,7 +29,7 @@ import io.javaoperatorsdk.operator.api.config.LeaderElectionConfigurationBuilder; import io.javaoperatorsdk.operator.config.loader.provider.AgregatePrirityListConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; -import io.javaoperatorsdk.operator.config.loader.provider.SystemPropertyConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.PropertiesConfigProvider; import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; import io.javaoperatorsdk.operator.processing.retry.GenericRetry; @@ -167,7 +167,7 @@ public static ConfigLoader getDefault() { public ConfigLoader() { this( new AgregatePrirityListConfigProvider( - List.of(new EnvVarConfigProvider(), new SystemPropertyConfigProvider())), + List.of(new EnvVarConfigProvider(), PropertiesConfigProvider.systemProperties())), DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); } diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java index 01ef5b4b03..35dd38f406 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/PropertiesConfigProvider.java @@ -36,6 +36,11 @@ public class PropertiesConfigProvider implements ConfigProvider { private final Properties properties; + /** Returns a {@link PropertiesConfigProvider} backed by {@link System#getProperties()}. */ + public static PropertiesConfigProvider systemProperties() { + return new PropertiesConfigProvider(System.getProperties()); + } + /** * Loads properties from the given file path. * diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java deleted file mode 100644 index f777eb378f..0000000000 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProvider.java +++ /dev/null @@ -1,53 +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.config.loader.provider; - -import java.util.Optional; -import java.util.function.Function; - -import io.javaoperatorsdk.operator.config.loader.ConfigProvider; - -/** - * A {@link ConfigProvider} that resolves configuration values from Java system properties via - * {@link System#getProperty(String)}, using the key as-is. - * - *

Supported value types are: {@link String}, {@link Boolean}, {@link Integer}, {@link Long}, - * {@link Double}, and {@link java.time.Duration} (ISO-8601 format, e.g. {@code PT30S}). - */ -public class SystemPropertyConfigProvider implements ConfigProvider { - - private final Function propertyLookup; - - public SystemPropertyConfigProvider() { - this(System::getProperty); - } - - SystemPropertyConfigProvider(Function propertyLookup) { - this.propertyLookup = propertyLookup; - } - - @Override - public Optional getValue(String key, Class type) { - if (key == null) { - return Optional.empty(); - } - String raw = propertyLookup.apply(key); - if (raw == null) { - return Optional.empty(); - } - return Optional.of(ConfigValueConverter.convert(raw, type)); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java index 85ec2f2d28..c4678b9810 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.config.loader.provider; import java.util.List; +import java.util.Properties; import org.junit.jupiter.api.Test; @@ -23,12 +24,19 @@ class PriorityListConfigProviderTest { + private static PropertiesConfigProvider propsProvider(String key, String value) { + Properties props = new Properties(); + if (key != null) { + props.setProperty(key, value); + } + return new PropertiesConfigProvider(props); + } + @Test void returnsEmptyWhenAllProvidersReturnEmpty() { var provider = new AgregatePrirityListConfigProvider( - List.of( - new EnvVarConfigProvider(k -> null), new SystemPropertyConfigProvider(k -> null))); + List.of(new EnvVarConfigProvider(k -> null), propsProvider(null, null))); assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); } @@ -38,8 +46,7 @@ void firstProviderWins() { new AgregatePrirityListConfigProvider( List.of( new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "first" : null), - new SystemPropertyConfigProvider( - k -> k.equals("josdk.test.key") ? "second" : null))); + propsProvider("josdk.test.key", "second"))); assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("first"); } @@ -49,16 +56,14 @@ void fallsBackToLaterProviderWhenEarlierReturnsEmpty() { new AgregatePrirityListConfigProvider( List.of( new EnvVarConfigProvider(k -> null), - new SystemPropertyConfigProvider( - k -> k.equals("josdk.test.key") ? "from-second" : null))); + propsProvider("josdk.test.key", "from-second"))); assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); } @Test void respectsOrderWithThreeProviders() { var first = new EnvVarConfigProvider(k -> null); - var second = - new SystemPropertyConfigProvider(k -> k.equals("josdk.test.key") ? "from-second" : null); + var second = propsProvider("josdk.test.key", "from-second"); var third = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "from-third" : null); var provider = new AgregatePrirityListConfigProvider(List.of(first, second, third)); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProviderTest.java deleted file mode 100644 index 2399524074..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/SystemPropertyConfigProviderTest.java +++ /dev/null @@ -1,108 +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.config.loader.provider; - -import java.time.Duration; -import java.util.concurrent.atomic.AtomicInteger; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; - -class SystemPropertyConfigProviderTest { - - @Test - void returnsEmptyWhenPropertyAbsent() { - var provider = new SystemPropertyConfigProvider(k -> null); - assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); - } - - @Test - void returnsEmptyForNullKey() { - var provider = new SystemPropertyConfigProvider(k -> "value"); - assertThat(provider.getValue(null, String.class)).isEmpty(); - } - - @Test - void readsStringFromSystemProperty() { - System.setProperty("josdk.test.string", "hello"); - try { - var provider = new SystemPropertyConfigProvider(); - assertThat(provider.getValue("josdk.test.string", String.class)).hasValue("hello"); - } finally { - System.clearProperty("josdk.test.string"); - } - } - - @Test - void readsBooleanFromSystemProperty() { - System.setProperty("josdk.test.bool", "true"); - try { - var provider = new SystemPropertyConfigProvider(); - assertThat(provider.getValue("josdk.test.bool", Boolean.class)).hasValue(true); - } finally { - System.clearProperty("josdk.test.bool"); - } - } - - @Test - void readsIntegerFromSystemProperty() { - System.setProperty("josdk.test.integer", "42"); - try { - var provider = new SystemPropertyConfigProvider(); - assertThat(provider.getValue("josdk.test.integer", Integer.class)).hasValue(42); - } finally { - System.clearProperty("josdk.test.integer"); - } - } - - @Test - void readsLongFromSystemProperty() { - System.setProperty("josdk.test.long", "123456789"); - try { - var provider = new SystemPropertyConfigProvider(); - assertThat(provider.getValue("josdk.test.long", Long.class)).hasValue(123456789L); - } finally { - System.clearProperty("josdk.test.long"); - } - } - - @Test - void readsDurationFromSystemProperty() { - System.setProperty("josdk.test.duration", "PT30S"); - try { - var provider = new SystemPropertyConfigProvider(); - assertThat(provider.getValue("josdk.test.duration", Duration.class)) - .hasValue(Duration.ofSeconds(30)); - } finally { - System.clearProperty("josdk.test.duration"); - } - } - - @Test - void throwsForUnsupportedType() { - System.setProperty("josdk.test.unsupported", "value"); - try { - var provider = new SystemPropertyConfigProvider(); - assertThatIllegalArgumentException() - .isThrownBy(() -> provider.getValue("josdk.test.unsupported", AtomicInteger.class)) - .withMessageContaining("Unsupported config type"); - } finally { - System.clearProperty("josdk.test.unsupported"); - } - } -} From 556020628cfbcae272596146fda4e3425a022dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 26 Feb 2026 17:49:41 +0100 Subject: [PATCH 53/58] remove smalltye proof of concept from sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- sample-operators/tomcat-operator/pom.xml | 23 ------------ .../operator/sample/TomcatOperator.java | 27 ++++---------- .../operator/sample/TomcatReconciler.java | 3 +- .../operator/sample/WebappReconciler.java | 3 +- .../SmallryeConfigProvider.java | 35 ------------------- 5 files changed, 9 insertions(+), 82 deletions(-) delete mode 100644 sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index c9afd825de..b7c3b05c98 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -39,13 +39,6 @@ pom import - - io.smallrye.config - smallrye-config-bom - 3.16.0 - pom - import - @@ -99,22 +92,6 @@ operator-framework-junit test - - - io.smallrye.config - smallrye-config - 3.11.4 - - - io.smallrye.config - smallrye-config-source-yaml - 3.11.4 - - - org.eclipse.microprofile.config - microprofile-config-api - 3.1 - diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index 7cc5d9027e..bceaae8363 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -16,38 +16,25 @@ package io.javaoperatorsdk.operator.sample; import java.io.IOException; -import java.net.URL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.takes.facets.fork.FkRegex; import org.takes.facets.fork.TkFork; import org.takes.http.Exit; import org.takes.http.FtBasic; import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.config.loader.ConfigLoader; -import io.javaoperatorsdk.operator.sample.smallryeconfig.SmallryeConfigProvider; -import io.smallrye.config.SmallRyeConfigBuilder; -import io.smallrye.config.source.yaml.YamlConfigSource; public class TomcatOperator { + private static final Logger log = LoggerFactory.getLogger(TomcatOperator.class); + public static void main(String[] args) throws IOException { - URL configUrl = TomcatOperator.class.getResource("/application.yaml"); - if (configUrl == null) { - throw new IllegalStateException("application.yaml not found on classpath"); - } - var configLoader = - new ConfigLoader( - new SmallryeConfigProvider( - new SmallRyeConfigBuilder().withSources(new YamlConfigSource(configUrl)).build())); - - Operator operator = new Operator(configLoader.applyConfigs()); - operator.register( - new TomcatReconciler(), configLoader.applyControllerConfigs(TomcatReconciler.NAME)); - operator.register( - new WebappReconciler(operator.getKubernetesClient()), - configLoader.applyControllerConfigs(WebappReconciler.NAME)); + Operator operator = new Operator(); + operator.register(new TomcatReconciler()); + operator.register(new WebappReconciler(operator.getKubernetesClient())); operator.start(); new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD.")), 8080).start(Exit.NEVER); diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java index 6bb454eb13..d2fa9a021f 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java @@ -35,10 +35,9 @@ @Dependent(type = DeploymentDependentResource.class), @Dependent(type = ServiceDependentResource.class) }) -@ControllerConfiguration(name = TomcatReconciler.NAME) +@ControllerConfiguration public class TomcatReconciler implements Reconciler { - public static final String NAME = "tomcat"; private final Logger log = LoggerFactory.getLogger(getClass()); @Override diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java index 32d32f0a5b..5d362113ba 100644 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java @@ -47,11 +47,10 @@ import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; -@ControllerConfiguration(name = WebappReconciler.NAME) +@ControllerConfiguration public class WebappReconciler implements Reconciler, Cleaner { private static final Logger log = LoggerFactory.getLogger(WebappReconciler.class); - public static final String NAME = "webapp"; private final KubernetesClient kubernetesClient; diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java deleted file mode 100644 index b415566af2..0000000000 --- a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/smallryeconfig/SmallryeConfigProvider.java +++ /dev/null @@ -1,35 +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.sample.smallryeconfig; - -import java.util.Optional; - -import io.javaoperatorsdk.operator.config.loader.ConfigProvider; -import io.smallrye.config.SmallRyeConfig; - -public class SmallryeConfigProvider implements ConfigProvider { - - private final SmallRyeConfig smallRyeConfig; - - public SmallryeConfigProvider(SmallRyeConfig smallRyeConfig) { - this.smallRyeConfig = smallRyeConfig; - } - - @Override - public Optional getValue(String key, Class type) { - return smallRyeConfig.getOptionalValue(key, type); - } -} From b4ca3a3f56f2c836e73e9792d7af89880a040a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 28 Feb 2026 11:21:48 +0100 Subject: [PATCH 54/58] fix naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- docs/content/en/docs/documentation/configuration.md | 6 +++--- .../operator/config/loader/ConfigLoader.java | 4 ++-- ...vider.java => AgregatePriorityListConfigProvider.java} | 4 ++-- .../operator/config/loader/ConfigLoaderTest.java | 2 +- .../loader/provider/PriorityListConfigProviderTest.java | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) rename operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/{AgregatePrirityListConfigProvider.java => AgregatePriorityListConfigProvider.java} (89%) diff --git a/docs/content/en/docs/documentation/configuration.md b/docs/content/en/docs/documentation/configuration.md index e761f7e1f5..34aa639525 100644 --- a/docs/content/en/docs/documentation/configuration.md +++ b/docs/content/en/docs/documentation/configuration.md @@ -189,7 +189,7 @@ Operator operator = new Operator(ConfigLoader.getDefault().applyConfigs()); | `EnvVarConfigProvider` | `System.getenv()` | dots and hyphens → underscores, upper-cased (`josdk.check-crd` → `JOSDK_CHECK_CRD`) | | `PropertiesConfigProvider` | `java.util.Properties` or `.properties` file | key used as-is; use `PropertiesConfigProvider.systemProperties()` to read Java system properties | | `YamlConfigProvider` | YAML file | dot-separated key traverses nested mappings | -| `AgregatePrirityListConfigProvider` | ordered list of providers | first non-empty result wins | +| `AgregatePriorityListConfigProvider` | ordered list of providers | first non-empty result wins | All string-based providers convert values to the target type automatically. Supported types: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Duration` (ISO-8601, e.g. `PT30S`). @@ -244,11 +244,11 @@ Only keys that are actually present in the source are applied; everything else r programmatic or annotation-based default. You can also compose multiple sources with explicit priority using -`AgregatePrirityListConfigProvider`: +`AgregatePriorityListConfigProvider`: ```java var configLoader = new ConfigLoader( - new AgregatePrirityListConfigProvider(List.of( + new AgregatePriorityListConfigProvider(List.of( new EnvVarConfigProvider(), // highest priority PropertiesConfigProvider.systemProperties(), new YamlConfigProvider(Path.of("config/operator.yaml")) // lowest priority diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index cc3a044112..1114fb87e6 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -27,7 +27,7 @@ import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; import io.javaoperatorsdk.operator.api.config.LeaderElectionConfigurationBuilder; -import io.javaoperatorsdk.operator.config.loader.provider.AgregatePrirityListConfigProvider; +import io.javaoperatorsdk.operator.config.loader.provider.AgregatePriorityListConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.PropertiesConfigProvider; import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; @@ -166,7 +166,7 @@ public static ConfigLoader getDefault() { public ConfigLoader() { this( - new AgregatePrirityListConfigProvider( + new AgregatePriorityListConfigProvider( List.of(new EnvVarConfigProvider(), PropertiesConfigProvider.systemProperties())), DEFAULT_CONTROLLER_KEY_PREFIX, DEFAULT_OPERATOR_KEY_PREFIX); diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePrirityListConfigProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java similarity index 89% rename from operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePrirityListConfigProvider.java rename to operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java index 8da7d28f2f..5190156ce5 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePrirityListConfigProvider.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/provider/AgregatePriorityListConfigProvider.java @@ -24,11 +24,11 @@ * A {@link ConfigProvider} that delegates to an ordered list of providers. Providers are queried in * list order; the first non-empty result wins. */ -public class AgregatePrirityListConfigProvider implements ConfigProvider { +public class AgregatePriorityListConfigProvider implements ConfigProvider { private final List providers; - public AgregatePrirityListConfigProvider(List providers) { + public AgregatePriorityListConfigProvider(List providers) { this.providers = List.copyOf(providers); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index 4fe9d50c2a..b238f5dee4 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -240,7 +240,7 @@ public Optional getValue(String key, Class type) { // -- binding coverage ------------------------------------------------------- /** - * Supported scalar types that AgregatePrirityListConfigProvider can parse from a string. Every + * Supported scalar types that AgregatePriorityListConfigProvider can parse from a string. Every * binding's type must be one of these. */ private static final Set> SUPPORTED_TYPES = diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java index c4678b9810..ad2a332868 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/provider/PriorityListConfigProviderTest.java @@ -35,7 +35,7 @@ private static PropertiesConfigProvider propsProvider(String key, String value) @Test void returnsEmptyWhenAllProvidersReturnEmpty() { var provider = - new AgregatePrirityListConfigProvider( + new AgregatePriorityListConfigProvider( List.of(new EnvVarConfigProvider(k -> null), propsProvider(null, null))); assertThat(provider.getValue("josdk.no.such.key", String.class)).isEmpty(); } @@ -43,7 +43,7 @@ void returnsEmptyWhenAllProvidersReturnEmpty() { @Test void firstProviderWins() { var provider = - new AgregatePrirityListConfigProvider( + new AgregatePriorityListConfigProvider( List.of( new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "first" : null), propsProvider("josdk.test.key", "second"))); @@ -53,7 +53,7 @@ void firstProviderWins() { @Test void fallsBackToLaterProviderWhenEarlierReturnsEmpty() { var provider = - new AgregatePrirityListConfigProvider( + new AgregatePriorityListConfigProvider( List.of( new EnvVarConfigProvider(k -> null), propsProvider("josdk.test.key", "from-second"))); @@ -66,7 +66,7 @@ void respectsOrderWithThreeProviders() { var second = propsProvider("josdk.test.key", "from-second"); var third = new EnvVarConfigProvider(k -> k.equals("JOSDK_TEST_KEY") ? "from-third" : null); - var provider = new AgregatePrirityListConfigProvider(List.of(first, second, third)); + var provider = new AgregatePriorityListConfigProvider(List.of(first, second, third)); assertThat(provider.getValue("josdk.test.key", String.class)).hasValue("from-second"); } } From 4716cabd7f49fb4c6d6b3f5fd77b048517ca9b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 28 Feb 2026 11:23:34 +0100 Subject: [PATCH 55/58] cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../src/main/resources/application.yaml | 62 ------------------- 1 file changed, 62 deletions(-) delete mode 100644 sample-operators/tomcat-operator/src/main/resources/application.yaml diff --git a/sample-operators/tomcat-operator/src/main/resources/application.yaml b/sample-operators/tomcat-operator/src/main/resources/application.yaml deleted file mode 100644 index 128ee5eabc..0000000000 --- a/sample-operators/tomcat-operator/src/main/resources/application.yaml +++ /dev/null @@ -1,62 +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. -# - -# JOSDK operator-level configuration (josdk.) -josdk: - check-crd: true - close-client-on-stop: true - use-ssa-to-patch-primary-resource: false - clone-secondary-resources-when-getting-from-cache: false - reconciliation: - termination-timeout: PT30S - concurrent-threads: 10 - workflow: - executor-threads: 10 - informer: - stop-on-error-during-startup: true - cache-sync-timeout: PT2M - dependent-resources: - ssa-based-create-update-match: true - - # Controller-level configuration (josdk.controller..) - controller: - tomcat: - finalizer: tomcat.sample.javaoperatorsdk.io/finalizer - generation-aware: true - label-selector: "" - max-reconciliation-interval: PT10M - field-manager: tomcat-controller - trigger-reconciler-on-all-events: false - informer-list-limit: 500 - retry: - max-attempts: 10 - initial-interval: 2000 - interval-multiplier: 1.5 - max-interval: 60000 - - webapp: - finalizer: webapp.sample.javaoperatorsdk.io/finalizer - generation-aware: true - label-selector: "" - max-reconciliation-interval: PT10M - field-manager: webapp-controller - trigger-reconciler-on-all-events: false - informer-list-limit: 500 - retry: - max-attempts: 10 - initial-interval: 2000 - interval-multiplier: 1.5 - max-interval: 60000 From e82b9630b1146033ff1206418514459ee5d2624f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 28 Feb 2026 16:30:34 +0100 Subject: [PATCH 56/58] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/config/loader/ConfigLoader.java | 3 +- .../config/loader/ConfigLoaderTest.java | 154 +++++++++++++++++- 2 files changed, 154 insertions(+), 3 deletions(-) diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index 1114fb87e6..d46a6116d7 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -203,8 +203,7 @@ public Consumer applyConfigs() { /** * Returns a {@link Consumer} that applies every controller-level property found in the {@link * ConfigProvider} to the given {@link ControllerConfigurationOverrider}. The keys are looked up - * as {@code josdk.controller..}. Returns {@code null} when no binding - * has a matching value. + * as {@code josdk.controller..}. */ @SuppressWarnings("unchecked") public diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index b238f5dee4..1fc1ebe98f 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -390,7 +390,159 @@ void leaderElectionMissingLeaseNameThrowsWhenOtherPropertiesPresent() { .withMessageContaining("lease-name"); } - /** Returns true when the two types are the same or one is the boxed/unboxed form of the other. */ + // -- retry ------------------------------------------------------------------ + + /** A minimal reconciler used to obtain a base ControllerConfiguration in retry tests. */ + @io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration + private static class DummyReconciler + implements io.javaoperatorsdk.operator.api.reconciler.Reconciler< + io.fabric8.kubernetes.api.model.ConfigMap> { + @Override + public io.javaoperatorsdk.operator.api.reconciler.UpdateControl< + io.fabric8.kubernetes.api.model.ConfigMap> + reconcile( + io.fabric8.kubernetes.api.model.ConfigMap r, + io.javaoperatorsdk.operator.api.reconciler.Context< + io.fabric8.kubernetes.api.model.ConfigMap> + ctx) { + return io.javaoperatorsdk.operator.api.reconciler.UpdateControl.noUpdate(); + } + } + + private static io.javaoperatorsdk.operator.api.config.ControllerConfiguration< + io.fabric8.kubernetes.api.model.ConfigMap> + baseControllerConfig() { + return new BaseConfigurationService().getConfigurationFor(new DummyReconciler()); + } + + private static io.javaoperatorsdk.operator.processing.retry.GenericRetry applyAndGetRetry( + java.util.function.Consumer< + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider< + io.fabric8.kubernetes.api.model.ConfigMap>> + consumer) { + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + consumer.accept(overrider); + return (io.javaoperatorsdk.operator.processing.retry.GenericRetry) overrider.build().getRetry(); + } + + @Test + void retryIsNotConfiguredWhenNoRetryPropertiesPresent() { + var loader = new ConfigLoader(mapProvider(Map.of())); + var consumer = loader.applyControllerConfigs("ctrl"); + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + consumer.accept(overrider); + // no retry property set → retry stays at the controller's default (null or unchanged) + var result = overrider.build(); + // The consumer must not throw and the config is buildable + assertThat(result).isNotNull(); + } + + @Test + void retryQueriesExpectedKeys() { + var queriedKeys = new ArrayList(); + ConfigProvider recordingProvider = + new ConfigProvider() { + @Override + public Optional getValue(String key, Class type) { + queriedKeys.add(key); + return Optional.empty(); + } + }; + new ConfigLoader(recordingProvider).applyControllerConfigs("ctrl"); + assertThat(queriedKeys) + .contains( + "josdk.controller.ctrl.retry.max-attempts", + "josdk.controller.ctrl.retry.initial-interval", + "josdk.controller.ctrl.retry.interval-multiplier", + "josdk.controller.ctrl.retry.max-interval"); + } + + @Test + void retryMaxAttemptsIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-attempts", 10))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(10); + // other fields stay at their defaults + assertThat(retry.getInitialInterval()) + .isEqualTo( + io.javaoperatorsdk.operator.processing.retry.GenericRetry + .defaultLimitedExponentialRetry() + .getInitialInterval()); + } + + @Test + void retryInitialIntervalIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.initial-interval", 500L))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getInitialInterval()).isEqualTo(500L); + } + + @Test + void retryIntervalMultiplierIsApplied() { + var loader = + new ConfigLoader( + mapProvider(Map.of("josdk.controller.ctrl.retry.interval-multiplier", 2.0))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getIntervalMultiplier()).isEqualTo(2.0); + } + + @Test + void retryMaxIntervalIsApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-interval", 30000L))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxInterval()).isEqualTo(30000L); + } + + @Test + void retryAllPropertiesApplied() { + var values = new HashMap(); + values.put("josdk.controller.ctrl.retry.max-attempts", 7); + values.put("josdk.controller.ctrl.retry.initial-interval", 1000L); + values.put("josdk.controller.ctrl.retry.interval-multiplier", 3.0); + values.put("josdk.controller.ctrl.retry.max-interval", 60000L); + var loader = new ConfigLoader(mapProvider(values)); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(7); + assertThat(retry.getInitialInterval()).isEqualTo(1000L); + assertThat(retry.getIntervalMultiplier()).isEqualTo(3.0); + assertThat(retry.getMaxInterval()).isEqualTo(60000L); + } + + @Test + void retryStartsFromDefaultLimitedExponentialRetryDefaults() { + // Only max-attempts is overridden — other fields must still be the defaults. + var defaults = + io.javaoperatorsdk.operator.processing.retry.GenericRetry.defaultLimitedExponentialRetry(); + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.retry.max-attempts", 3))); + var retry = applyAndGetRetry(loader.applyControllerConfigs("ctrl")); + assertThat(retry.getMaxAttempts()).isEqualTo(3); + assertThat(retry.getInitialInterval()).isEqualTo(defaults.getInitialInterval()); + assertThat(retry.getIntervalMultiplier()).isEqualTo(defaults.getIntervalMultiplier()); + assertThat(retry.getMaxInterval()).isEqualTo(defaults.getMaxInterval()); + } + + @Test + void retryIsIsolatedPerControllerName() { + var values = new HashMap(); + values.put("josdk.controller.alpha.retry.max-attempts", 4); + values.put("josdk.controller.beta.retry.max-attempts", 9); + var loader = new ConfigLoader(mapProvider(values)); + + var alphaRetry = applyAndGetRetry(loader.applyControllerConfigs("alpha")); + var betaRetry = applyAndGetRetry(loader.applyControllerConfigs("beta")); + + assertThat(alphaRetry.getMaxAttempts()).isEqualTo(4); + assertThat(betaRetry.getMaxAttempts()).isEqualTo(9); + } + private static boolean isTypeCompatible(Class methodParam, Class bindingType) { if (methodParam == bindingType) return true; if (methodParam == boolean.class && bindingType == Boolean.class) return true; From 34a249f8a607facd27950ebd6626302894fc043d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 28 Feb 2026 18:05:05 +0100 Subject: [PATCH 57/58] integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../baseapi/configloader/ConfigLoaderIT.java | 146 ++++++++++++++++++ .../ConfigLoaderTestCustomResource.java | 30 ++++ .../ConfigLoaderTestCustomResourceStatus.java | 35 +++++ .../ConfigLoaderTestReconciler.java | 58 +++++++ 4 files changed, 269 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java new file mode 100644 index 0000000000..d1ee0afa59 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java @@ -0,0 +1,146 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.config.loader.ConfigLoader; +import io.javaoperatorsdk.operator.config.loader.ConfigProvider; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.TestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration tests that verify {@link ConfigLoader} property overrides take effect when wiring up + * a real operator instance via {@link LocallyRunOperatorExtension}. + * + *

Each nested class exercises a distinct group of properties so that failures are easy to + * pinpoint. + */ +class ConfigLoaderIT { + + /** Builds a {@link ConfigProvider} backed by a plain map. */ + private static ConfigProvider mapProvider(Map values) { + return new ConfigProvider() { + @Override + @SuppressWarnings("unchecked") + public Optional getValue(String key, Class type) { + return Optional.ofNullable((T) values.get(key)); + } + }; + } + + // --------------------------------------------------------------------------- + // Operator-level properties + // --------------------------------------------------------------------------- + + @Nested + class OperatorLevelProperties { + + /** + * Verifies that {@code josdk.reconciliation.concurrent-threads} loaded via {@link ConfigLoader} + * and applied through {@code withConfigurationService} actually changes the operator's thread + * pool size. + */ + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new ConfigLoaderTestReconciler(0)) + .withConfigurationService( + new ConfigLoader(mapProvider(Map.of("josdk.reconciliation.concurrent-threads", 2))) + .applyConfigs()) + .build(); + + @Test + void concurrentReconciliationThreadsIsAppliedFromConfigLoader() { + assertThat(operator.getOperator().getConfigurationService().concurrentReconciliationThreads()) + .isEqualTo(2); + } + } + + // --------------------------------------------------------------------------- + // Controller-level retry + // --------------------------------------------------------------------------- + + @Nested + class ControllerRetryProperties { + + static final int FAILS = 2; + // controller name is the lower-cased simple class name by default + static final String CTRL_NAME = ConfigLoaderTestReconciler.class.getSimpleName().toLowerCase(); + + /** + * Verifies that retry properties read by {@link ConfigLoader} for a specific controller name + * are applied when registering the reconciler via a {@code configurationOverrider} consumer, + * and that the resulting operator actually retries and eventually succeeds. + */ + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + new ConfigLoaderTestReconciler(FAILS), + // applyControllerConfigs returns Consumer>; + // withReconciler takes the raw Consumer + (Consumer) + (Consumer) + new ConfigLoader( + mapProvider( + Map.of( + "josdk.controller." + CTRL_NAME + ".retry.max-attempts", + 5, + "josdk.controller." + CTRL_NAME + ".retry.initial-interval", + 100L))) + .applyControllerConfigs(CTRL_NAME)) + .build(); + + @Test + void retryConfigFromConfigLoaderIsAppliedAndReconcilerEventuallySucceeds() { + var resource = createResource("1"); + operator.create(resource); + + await("reconciler succeeds after retries") + .atMost(10, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> { + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(FAILS + 1); + var updated = + operator.get( + ConfigLoaderTestCustomResource.class, resource.getMetadata().getName()); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getState()) + .isEqualTo(ConfigLoaderTestCustomResourceStatus.State.SUCCESS); + }); + } + + private ConfigLoaderTestCustomResource createResource(String id) { + var resource = new ConfigLoaderTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("cfgloader-retry-" + id).build()); + return resource; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java new file mode 100644 index 0000000000..a892b2391d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResource.java @@ -0,0 +1,30 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("ConfigLoaderSample") +@ShortNames("cls") +public class ConfigLoaderTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java new file mode 100644 index 0000000000..c70202bb73 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestCustomResourceStatus.java @@ -0,0 +1,35 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +public class ConfigLoaderTestCustomResourceStatus { + + public enum State { + SUCCESS, + ERROR + } + + private State state; + + public State getState() { + return state; + } + + public ConfigLoaderTestCustomResourceStatus setState(State state) { + this.state = state; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java new file mode 100644 index 0000000000..dbadfd4414 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderTestReconciler.java @@ -0,0 +1,58 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.configloader; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * A reconciler that fails for the first {@code numberOfFailures} invocations and then succeeds, + * setting the status to {@link ConfigLoaderTestCustomResourceStatus.State#SUCCESS}. + */ +@ControllerConfiguration +public class ConfigLoaderTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final int numberOfFailures; + + public ConfigLoaderTestReconciler(int numberOfFailures) { + this.numberOfFailures = numberOfFailures; + } + + @Override + public UpdateControl reconcile( + ConfigLoaderTestCustomResource resource, Context context) { + int execution = numberOfExecutions.incrementAndGet(); + if (execution <= numberOfFailures) { + throw new RuntimeException("Simulated failure on execution " + execution); + } + var status = new ConfigLoaderTestCustomResourceStatus(); + status.setState(ConfigLoaderTestCustomResourceStatus.State.SUCCESS); + resource.setStatus(status); + return UpdateControl.patchStatus(resource); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} From 5cc2d0bd845155f10632bc7834f99a4e3fd63cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 28 Feb 2026 18:34:17 +0100 Subject: [PATCH 58/58] feat: template helm chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- helm/operator/Chart.yaml | 18 +++ helm/operator/templates/NOTES.txt | 45 ++++++ helm/operator/templates/_helpers.tpl | 93 +++++++++++ helm/operator/templates/clusterrole.yaml | 44 +++++ helm/operator/templates/configmap-josdk.yaml | 31 ++++ helm/operator/templates/configmap-log4j2.yaml | 37 +++++ helm/operator/templates/deployment.yaml | 130 +++++++++++++++ helm/operator/templates/serviceaccount.yaml | 11 ++ helm/operator/values.yaml | 153 ++++++++++++++++++ 9 files changed, 562 insertions(+) create mode 100644 helm/operator/Chart.yaml create mode 100644 helm/operator/templates/NOTES.txt create mode 100644 helm/operator/templates/_helpers.tpl create mode 100644 helm/operator/templates/clusterrole.yaml create mode 100644 helm/operator/templates/configmap-josdk.yaml create mode 100644 helm/operator/templates/configmap-log4j2.yaml create mode 100644 helm/operator/templates/deployment.yaml create mode 100644 helm/operator/templates/serviceaccount.yaml create mode 100644 helm/operator/values.yaml diff --git a/helm/operator/Chart.yaml b/helm/operator/Chart.yaml new file mode 100644 index 0000000000..9ba424eb54 --- /dev/null +++ b/helm/operator/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: josdk-operator +description: > + Generic Helm chart template for deploying a Java Operator SDK based operator. + Copy and customise this chart for your own operator: adjust the values, extend + the ClusterRole rules with your CRD API groups, and optionally add your own + templates. +type: application +version: 0.1.0 +# Set to the version of your operator image. +appVersion: "latest" +keywords: + - operator + - kubernetes + - java-operator-sdk +home: https://javaoperatorsdk.io +sources: + - https://github.com/operator-framework/java-operator-sdk diff --git a/helm/operator/templates/NOTES.txt b/helm/operator/templates/NOTES.txt new file mode 100644 index 0000000000..319f42fe6e --- /dev/null +++ b/helm/operator/templates/NOTES.txt @@ -0,0 +1,45 @@ +Thank you for installing {{ .Chart.Name }} ({{ .Chart.AppVersion }}). + +Release: {{ .Release.Name }} +Namespace: {{ include "josdk-operator.namespace" . }} + +Operator deployment: {{ include "josdk-operator.fullname" . }} + +{{- if .Values.josdkConfig.enabled }} + +ConfigLoader properties are mounted from ConfigMap + {{ include "josdk-operator.configMapName" . }} +at {{ .Values.josdkConfig.mountPath }}/josdk.properties. + +Wire it up in your operator main class: + + ConfigLoader loader = new ConfigLoader( + PropertiesConfigProvider.fromFile( + Path.of("{{ .Values.josdkConfig.mountPath }}/josdk.properties"))); +{{- end }} + +{{- if .Values.log4j2.enabled }} + +Log4j2 configuration is mounted from ConfigMap + {{ include "josdk-operator.log4j2ConfigMapName" . }} +at {{ .Values.log4j2.mountPath }}/log4j2.xml. + +Root log level: {{ .Values.log4j2.rootLevel }} +{{- if .Values.log4j2.loggers }} +Per-logger overrides: +{{- range $logger, $level := .Values.log4j2.loggers }} + {{ $logger }} -> {{ $level }} +{{- end }} +{{- end }} + +The JVM flag -Dlog4j2.configurationFile={{ .Values.log4j2.mountPath }}/log4j2.xml +has been added to JAVA_TOOL_OPTIONS automatically. +{{- end }} + +To change the log level at runtime without redeploying, update the ConfigMap: + + kubectl edit configmap {{ include "josdk-operator.log4j2ConfigMapName" . }} \ + -n {{ include "josdk-operator.namespace" . }} + +Log4j2 will pick up the change within 30 seconds (monitorInterval="30" in the +default configuration). diff --git a/helm/operator/templates/_helpers.tpl b/helm/operator/templates/_helpers.tpl new file mode 100644 index 0000000000..6adb60ebe7 --- /dev/null +++ b/helm/operator/templates/_helpers.tpl @@ -0,0 +1,93 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "josdk-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "josdk-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart label. +*/}} +{{- define "josdk-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels applied to every resource. +*/}} +{{- define "josdk-operator.labels" -}} +helm.sh/chart: {{ include "josdk-operator.chart" . }} +{{ include "josdk-operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels used in Deployment and Service selectors. +*/}} +{{- define "josdk-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "josdk-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name. +*/}} +{{- define "josdk-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "josdk-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Deployment namespace. +*/}} +{{- define "josdk-operator.namespace" -}} +{{- default .Release.Namespace .Values.namespace }} +{{- end }} + +{{/* +Name of the JOSDK config ConfigMap. +*/}} +{{- define "josdk-operator.configMapName" -}} +{{- default (printf "%s-config" (include "josdk-operator.fullname" .)) .Values.josdkConfig.configMapName }} +{{- end }} + +{{/* +Name of the log4j2 ConfigMap. +*/}} +{{- define "josdk-operator.log4j2ConfigMapName" -}} +{{- default (printf "%s-log4j2" (include "josdk-operator.fullname" .)) .Values.log4j2.configMapName }} +{{- end }} + +{{/* +JAVA_TOOL_OPTIONS / JVM args value. +Appends the log4j2 config file system property automatically when log4j2 is enabled. +*/}} +{{- define "josdk-operator.jvmArgs" -}} +{{- $args := .Values.jvmArgs | default "" }} +{{- if .Values.log4j2.enabled }} +{{- $args = printf "%s -Dlog4j2.configurationFile=%s/log4j2.xml" $args .Values.log4j2.mountPath | trim }} +{{- end }} +{{- $args }} +{{- end }} diff --git a/helm/operator/templates/clusterrole.yaml b/helm/operator/templates/clusterrole.yaml new file mode 100644 index 0000000000..92000cb618 --- /dev/null +++ b/helm/operator/templates/clusterrole.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "josdk-operator.fullname" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +rules: + # Required for JOSDK to install / validate CRDs on startup. + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + # Required for leader-election (if used). + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Required for JOSDK event-source on ConfigMaps / Secrets (optional; remove if not needed). + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + # Add your operator's custom resource rules here via values.rbac.additionalRules, e.g.: + # additionalRules: + # - apiGroups: ["mygroup.example.com"] + # resources: ["myresources", "myresources/status"] + # verbs: ["*"] + {{- with .Values.rbac.additionalRules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "josdk-operator.fullname" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "josdk-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "josdk-operator.serviceAccountName" . }} + namespace: {{ include "josdk-operator.namespace" . }} +{{- end }} diff --git a/helm/operator/templates/configmap-josdk.yaml b/helm/operator/templates/configmap-josdk.yaml new file mode 100644 index 0000000000..cd09277b2b --- /dev/null +++ b/helm/operator/templates/configmap-josdk.yaml @@ -0,0 +1,31 @@ +{{- if .Values.josdkConfig.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "josdk-operator.configMapName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +data: + # All key-value pairs are written into josdk.properties. + # The operator reads this file via a file-backed ConfigProvider and passes it + # to ConfigLoader. See the ConfigLoader javadoc for supported property keys. + # + # Example operator-level keys: + # josdk.reconciliation.concurrent-threads=4 + # josdk.informer.stop-on-error-during-startup=false + # josdk.informer.cache-sync-timeout=PT30S + # + # Example controller-level keys (replace "my-controller" with the lower-cased + # reconciler class name): + # josdk.controller.my-controller.retry.max-attempts=5 + # josdk.controller.my-controller.retry.initial-interval=1000 + # josdk.controller.my-controller.retry.interval-multiplier=1.5 + # josdk.controller.my-controller.retry.max-interval=60000 + # josdk.controller.my-controller.rate-limiter.limit-for-period=10 + # josdk.controller.my-controller.rate-limiter.refresh-period=PT1S + josdk.properties: | + {{- range $key, $value := .Values.josdkConfig.properties }} + {{ $key }}={{ $value }} + {{- end }} +{{- end }} diff --git a/helm/operator/templates/configmap-log4j2.yaml b/helm/operator/templates/configmap-log4j2.yaml new file mode 100644 index 0000000000..2c630ac0bf --- /dev/null +++ b/helm/operator/templates/configmap-log4j2.yaml @@ -0,0 +1,37 @@ +{{- if .Values.log4j2.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "josdk-operator.log4j2ConfigMapName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +data: + log4j2.xml: | + {{- if .Values.log4j2.xmlOverride }} + {{- .Values.log4j2.xmlOverride | nindent 4 }} + {{- else }} + + + + + + + + + + {{- range $logger, $level := .Values.log4j2.loggers }} + + + + {{- end }} + + + + + + {{- end }} +{{- end }} diff --git a/helm/operator/templates/deployment.yaml b/helm/operator/templates/deployment.yaml new file mode 100644 index 0000000000..efbcca02d5 --- /dev/null +++ b/helm/operator/templates/deployment.yaml @@ -0,0 +1,130 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "josdk-operator.fullname" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "josdk-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "josdk-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "josdk-operator.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: operator + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + # ------------------------------------------------------------------ + # Environment variables. + # JAVA_TOOL_OPTIONS is set when jvmArgs is non-empty or log4j2 is + # enabled (the helper appends -Dlog4j2.configurationFile). + # extraEnv entries are appended after. + # ------------------------------------------------------------------ + {{- $jvmArgs := include "josdk-operator.jvmArgs" . | trim }} + {{- if or $jvmArgs .Values.extraEnv }} + env: + {{- if $jvmArgs }} + - name: JAVA_TOOL_OPTIONS + value: {{ $jvmArgs | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + {{- if .Values.healthProbes.enabled }} + ports: + - name: health + containerPort: {{ .Values.healthProbes.port }} + protocol: TCP + startupProbe: + httpGet: + path: {{ .Values.healthProbes.startupProbe.path }} + port: {{ .Values.healthProbes.port }} + initialDelaySeconds: {{ .Values.healthProbes.startupProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.healthProbes.startupProbe.periodSeconds }} + failureThreshold: {{ .Values.healthProbes.startupProbe.failureThreshold }} + livenessProbe: + httpGet: + path: {{ .Values.healthProbes.livenessProbe.path }} + port: {{ .Values.healthProbes.port }} + initialDelaySeconds: {{ .Values.healthProbes.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.healthProbes.livenessProbe.periodSeconds }} + failureThreshold: {{ .Values.healthProbes.livenessProbe.failureThreshold }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + # ---------------------------------------------------------------- + # JOSDK ConfigLoader properties file + # Mounted at /josdk.properties. + # Wire it up in your operator main class, e.g.: + # + # ConfigLoader loader = new ConfigLoader( + # PropertiesConfigProvider.fromFile( + # Path.of("/config/josdk.properties"))); + # ---------------------------------------------------------------- + {{- if .Values.josdkConfig.enabled }} + - name: josdk-config + mountPath: {{ .Values.josdkConfig.mountPath }} + readOnly: true + {{- end }} + # ---------------------------------------------------------------- + # Log4j2 configuration file + # Mounted at /log4j2.xml. + # Picked up automatically via JAVA_TOOL_OPTIONS when log4j2.enabled=true. + # ---------------------------------------------------------------- + {{- if .Values.log4j2.enabled }} + - name: log4j2-config + mountPath: {{ .Values.log4j2.mountPath }} + readOnly: true + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + {{- if .Values.josdkConfig.enabled }} + - name: josdk-config + configMap: + name: {{ include "josdk-operator.configMapName" . }} + {{- end }} + {{- if .Values.log4j2.enabled }} + - name: log4j2-config + configMap: + name: {{ include "josdk-operator.log4j2ConfigMapName" . }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/operator/templates/serviceaccount.yaml b/helm/operator/templates/serviceaccount.yaml new file mode 100644 index 0000000000..c31888988b --- /dev/null +++ b/helm/operator/templates/serviceaccount.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "josdk-operator.serviceAccountName" . }} + namespace: {{ include "josdk-operator.namespace" . }} + labels: + {{- include "josdk-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} diff --git a/helm/operator/values.yaml b/helm/operator/values.yaml new file mode 100644 index 0000000000..bdf493dcdd --- /dev/null +++ b/helm/operator/values.yaml @@ -0,0 +1,153 @@ +# ----------------------------------------------------------------------- +# Generic JOSDK Operator Helm Chart – default values +# Override any of these in your own values.yaml or with --set on the CLI. +# ----------------------------------------------------------------------- + +# -- Operator identity ------------------------------------------------------- +nameOverride: "" +fullnameOverride: "" + +# -- Image ------------------------------------------------------------------- +image: + repository: my-operator + tag: latest + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# -- Replicas ---------------------------------------------------------------- +replicaCount: 1 + +# -- Namespace the operator is deployed into. +# Defaults to the Helm release namespace (.Release.Namespace). +namespace: "" + +# -- Service account --------------------------------------------------------- +serviceAccount: + # Create a dedicated ServiceAccount for the operator. + create: true + # Annotations to add (e.g. for IRSA / Workload Identity). + annotations: {} + # Override the auto-generated name. + name: "" + +# -- Pod settings ------------------------------------------------------------ +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 2000 + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +# -- Health probes ----------------------------------------------------------- +# Requires the operator to expose an HTTP health endpoint. +# The JOSDK sample operators expose /startup and /healthz on port 8080 by +# default; adjust if your operator uses different paths or ports. +healthProbes: + enabled: false + port: 8080 + startupProbe: + path: /startup + initialDelaySeconds: 5 + periodSeconds: 2 + failureThreshold: 15 + livenessProbe: + path: /healthz + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + +# -- RBAC -------------------------------------------------------------------- +rbac: + # Create ClusterRole + ClusterRoleBinding. + create: true + # Additional rules appended to the ClusterRole. + # Add entries for your operator's custom resources here, e.g.: + # - apiGroups: ["mygroup.example.com"] + # resources: ["myresources", "myresources/status"] + # verbs: ["*"] + additionalRules: [] + +# -- ConfigLoader configuration ConfigMap ------------------------------------ +# When enabled, a ConfigMap is created and mounted into the operator pod at +# /config/josdk.properties (or josdk.yaml). The operator must be coded to +# load it via ConfigLoader / ConfigProvider (or Spring, etc.). +# All key-value pairs under `config.properties` are written verbatim into the +# ConfigMap data entry `josdk.properties`. Use the JOSDK property keys +# documented in ConfigLoader (e.g. josdk.reconciliation.concurrent-threads). +josdkConfig: + enabled: false + # Name of the ConfigMap; defaults to -config. + configMapName: "" + # Mount path inside the container. + mountPath: /config + # Properties written into josdk.properties inside the ConfigMap. + # Example: + # properties: + # josdk.reconciliation.concurrent-threads: "4" + # josdk.workflow.executor-threads: "2" + # josdk.informer.stop-on-error-during-startup: "false" + # josdk.controller.my-controller.retry.max-attempts: "5" + # josdk.controller.my-controller.retry.initial-interval: "1000" + properties: {} + +# -- Log4j2 configuration ---------------------------------------------------- +# When enabled, a ConfigMap containing a log4j2.xml is created and mounted +# into the operator pod at /config/log4j2.xml. The operator must be launched +# with -Dlog4j2.configurationFile=/config/log4j2.xml (set via `jvmArgs` below) +# so that Log4j2 picks up the external file. +log4j2: + enabled: false + # Name of the ConfigMap; defaults to -log4j2. + configMapName: "" + # Mount path for the log4j2.xml file. + mountPath: /config + # Root log level (TRACE | DEBUG | INFO | WARN | ERROR | OFF). + rootLevel: INFO + # Per-logger overrides – map of logger-name → level. + # Example: + # loggers: + # io.javaoperatorsdk: DEBUG + # io.fabric8.kubernetes.client: WARN + loggers: {} + # Full override of the log4j2 XML content. When set, rootLevel and loggers + # are ignored and this raw XML is used instead. + xmlOverride: "" + +# -- Extra environment variables injected into the operator container -------- +# Example: +# extraEnv: +# - name: METRICS_CONSOLE_LOGGING +# value: "true" +extraEnv: [] + +# -- Extra volumes / mounts (user-defined, independent of the above) --------- +extraVolumes: [] +extraVolumeMounts: [] + +# -- JVM arguments passed to the operator process --------------------------- +# The log4j2 config file path is appended automatically when log4j2.enabled=true. +# Example: +# jvmArgs: "-Xmx256m -Xms128m" +jvmArgs: "" + +# -- Node scheduling --------------------------------------------------------- +nodeSelector: {} +tolerations: [] +affinity: {}